diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 00000000000..f0c2891ba4a --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,116 @@ +--- +name: code-review +description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy. +--- + +# Reviewing ESPHome pull requests + +ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers +(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and +flash are scarce, and code runs unattended for months. + +`AGENTS.md` in the repository root is the full contributor guide and the +authority when it disagrees with this summary. The developer documentation at +https://developers.esphome.io explains the component lifecycle and the reasoning +behind these rules. This skill lists the concrete things worth flagging in a +review; read `AGENTS.md` for the detail behind any item. + +Only raise findings that the diff actually introduces or changes. Do not ask for +drive-by cleanup of pre-existing code the PR did not touch. + +## Memory and embedded constraints (highest value) + +Heap allocation after `setup()` is treated as a reliability bug, not a +performance nit, because it fragments a small shared heap. Flag: + +- New heap allocation on a hot path or after setup that could be avoided. +- `std::vector` where the size is known at compile time (use `std::array`, or + `StaticVector` when a `push_back` API is needed) or fixed at runtime + init (use `FixedVector`). +- Listener / child-entity registration lists stored as `std::vector`; these have + a compile-time-known count and should use `cg.slot_counter()` plus + `StaticVector`. +- `std::vector` for a byte buffer that never grows: prefer + `std::unique_ptr` or `std::array`. +- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16 + elements): a `std::vector` of a small struct with linear search is lighter. +- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided. +- `std::string` storing a value set once from config: prefer `StringRef` (the + literal already lives in flash). +- `std::string` / `std::to_string` / string-returning helpers on hot paths where + a buffer or view API exists. + +## C++ conventions + +- Include what you use: a file referencing a symbol must include the header that + declares it, even if it currently arrives transitively. New or changed uses of + a symbol need the matching include. +- Prefix all member access with `this->`. +- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase` + for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants, + trailing underscore on protected/private fields. +- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE` + (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`, + `FAIL`, or `OK` collide with SDK macros on some platforms and break the build. +- Prefer `const`/`enum` over `#define`; `#define` is only for conditional + compilation and code-generation sizes. +- Never call `millis()` in a `loop()` body; use + `App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop + period) does nothing. +- Pick the timing primitive by cadence: gated `loop()` under 250 ms, + `set_interval` at 500 ms and above. +- Do not override a base method to return the value it already returns (e.g. + `get_setup_priority()` returning `setup_priority::DATA`). +- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`. +- Required, invariant dependencies should be constructor parameters, not setters. +- Callback registration methods must be templated (`template`), not + typed as `std::function`, so lightweight forwarders avoid a heap allocation. +- Two-space indent, `using` over `typedef`, wrap at 120 columns. + +## Python conventions + +- Type-annotate every new function signature (params and return), new dataclass + fields, and new module-level variables. Import `ConfigType` from + `esphome.types`. +- Use the walrus operator to avoid a double lookup, e.g. + `if (blah := config.get(CONF_BLAH)) is not None:`. +- Reuse existing validators from `config_validation.py` (`cv.rename_key`, + `cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling. +- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in + the component's own `.py`, or in `esphome/components/const/__init__.py` when + shared. The same constant defined in three or more component files fails CI. +- State that must persist during code generation goes in `CORE.data` namespaced + under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals. +- Prefer callback-based triggers via `build_callback_automation()`; only use a + `Trigger` subclass when the forwarder needs mutable state. + +## Testing and coverage + +- New and changed lines and branches need test coverage, including defensive + early-returns, error paths, and no-op guards. A mocked-out function is not + covered; exercise the real call path too. +- Component YAML tests live in `tests/components//`. Never define + buses (uart, i2c, spi, modbus) directly in a test file: pull them from + `tests/test_build_components/common/` through dict-style `packages:` so CI can + group builds. List-style packages or top-level merge keys block grouping. +- Config-only checks use the `validate.*.yaml` prefix; compiled checks use + `test.*.yaml`. + +## Breaking changes and public API + +- Base classes under `esphome/core/` and documented config options are public + API. Undocumented `public` members of a component are internal. +- A breaking change needs justification, a migration path in the PR description, + and a deprecation window where feasible (`ESPDEPRECATED` in C++, + `cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected + lambda signature is not a breaking change. + +## Process and PR hygiene + +- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`) + or `[core]` for shared code. +- Prose in docs, comments, and commit messages should be plain English. Keep + inline comments short and only where the code is not self-explanatory; do not + restate what the code says. +- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds + `CODEOWNERS` entries for a new component. diff --git a/.claude/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md similarity index 100% rename from .claude/skills/pr-workflow/SKILL.md rename to .agents/skills/pr-workflow/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6f7e8922849..8527cf491b0 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -31,7 +31,4 @@ RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 -COPY script/platformio_install_deps.py platformio.ini ./ -RUN ./platformio_install_deps.py platformio.ini --libraries --platforms --tools - WORKDIR /workspaces diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 44722ec85c7..2244963a79d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,29 @@ body: If you have a feature request or enhancement, please [request them here instead][fr]. [fr]: https://github.com/orgs/esphome/discussions + - type: markdown + attributes: + value: | + ## Use of AI in bug reports + + AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting. + Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved + your problem, its wild guesses are not likely to help. + + Please DO include your own words and observations, compile/boot logs, and + especially a minimal reproducible example of your YAML configuration that demonstrates the problem. + + It is however quite acceptable to use AI to translate your *own* report, + if you aren't a competent English speaker. + + If you really think it will be useful to include an AI's analysis, preferably wrap it in a `
` block which will be collapsed by default. + + If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is, + it can be more useful to ask it to create a step-by-step troubleshooting procedure. + AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML + configuration that demonstrates the problem. + + Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way. - type: textarea validations: required: true diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 133d7ca8d82..f6565a3916a 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false diff --git a/.github/actions/cache-arduino8266/action.yml b/.github/actions/cache-arduino8266/action.yml new file mode 100644 index 00000000000..c9e194d667e --- /dev/null +++ b/.github/actions/cache-arduino8266/action.yml @@ -0,0 +1,39 @@ +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 FRAMEWORK_RELEASES, TOOLCHAIN_VERSION as t; print(f"{FRAMEWORK_RELEASES[f].tag}-{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 }} diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index ce14b0152a8..affe8fc2d6b 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/skills b/.github/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 63219a1dbcd..d0bd1de9ef1 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 829bdd5f981..2784f135114 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -12,15 +12,14 @@ on: - ".github/workflows/ci-docker.yml" - "requirements*.txt" - "pyproject.toml" - - "platformio.ini" - "esphome/idf_component.yml" - - "script/platformio_install_deps.py" # Core, build pipeline, toolchain, and target-platform changes can change # how a toolchain is set up or built, so re-run the per-toolchain compile # smoke test when they change. - "esphome/core/**" - "esphome/writer.py" - "esphome/build_gen/**" + - "esphome/build_helpers/**" - "esphome/espidf/**" - "esphome/platformio/**" - "esphome/components/bk72xx/**" @@ -67,7 +66,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Determine tag and whether to push id: tag @@ -159,7 +158,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to the GitHub container registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -197,6 +196,7 @@ jobs: # the default. id: - esp8266-arduino + - esp8266-arduino-native - esp32-arduino-platformio - esp32-arduino-esp-idf - esp32-idf-platformio diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c93b3b867..d9507c59381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -102,6 +102,8 @@ jobs: device-builder: ${{ steps.determine.outputs.device-builder }} esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} 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-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -165,6 +167,8 @@ jobs: 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-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-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 @@ -183,6 +187,33 @@ jobs: path: .temp/components_graph.json 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 the component matrix, the memory impact jobs and + # test-esp8266-native restore. 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: name: Run script/ci-custom runs-on: ubuntu-24.04 @@ -244,11 +275,20 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Read prek version from requirements_test.txt + id: prek + # requirements_test.txt is the only place the version is pinned, so a + # Dependabot bump there is picked up here without a second edit. + run: | + if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then + echo "::error::No prek== pin found in requirements_test.txt." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: - # Keep in sync with requirements_test.txt. - prek-version: "0.4.11" + prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates # the cache on dev. Every run would miss and then write a per-pull # request copy, which is what the old seed-cache job existed to @@ -312,7 +352,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -341,7 +381,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: run_command: empty-upload force: true @@ -360,14 +400,9 @@ jobs: matrix: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} env: - # What the cache steps persist; libdeps is excluded (keyed per xdist - # worker and env, it never crosses runs). - INTEGRATION_PIO_CACHE_PATH: | - ~/.esphome-integration-tests/platformio/platforms - ~/.esphome-integration-tests/platformio/packages - ~/.esphome-integration-tests/platformio/appstate.json - ~/.esphome-integration-tests/platformio/.cache - ~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json + # Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds + # download, shared per xdist worker by tests/integration/conftest.py + INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -387,14 +422,13 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - - name: Restore integration PlatformIO cache - # Native platform + toolchain installed by shared_platformio_cache in - # tests/integration/conftest.py; a miss self-heals, so no restore-keys. - id: pio-cache + - name: Restore integration library cache + # A miss or a changed pin self-heals with a download, so no restore-keys + id: library-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -404,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -462,16 +496,16 @@ jobs: # A full cron period of margin for the weekly refresh retention-days: 14 - name: Print ccache statistics - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). - run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save integration PlatformIO cache + # esphome stores the host build's ccache under the machine-global + # cache dir (see get_build_env() in esphome/host/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s + - name: Save integration library cache # Bucket 0 only; the others would race the same immutable key. - if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true' + if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: ${{ steps.pio-cache.outputs.cache-primary-key }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: ${{ steps.library-cache.outputs.cache-primary-key }} import-time: name: Check import esphome.__main__ time @@ -1088,6 +1122,11 @@ jobs: uses: ./.github/actions/cache-sdk-nrf with: restore-only: true + - name: Cache the native ESP8266 toolchain + # Only batches whose test platforms include esp8266; never saves + # here, it reuses the install the dev seed job cached. + if: matrix.batch.needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -1187,8 +1226,20 @@ jobs: if [ -n "$compile_csv" ]; then # Run compilation with grouping and isolation python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + # The real idf.py must find nothing to configure or build in a + # tree built above; catches drift on ESP-IDF bumps. + echo "Checking the native ESP-IDF build matches idf.py" + python3 script/check_idf_py_equivalence.py + fi else echo "All components in this batch are validate-only -- skipping compile stage." + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + # determine-jobs and this step disagree on what compiles; fail + # rather than let the check run nowhere. + echo "::error::This batch was picked for the idf.py check but compiled nothing" + exit 1 + fi fi - name: Print ccache statistics @@ -1228,7 +1279,7 @@ jobs: # compile validates config first, so a separate config pass is # 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 + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests echo "" echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." @@ -1237,6 +1288,40 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + 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: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1265,7 +1350,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1406,12 +1491,9 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' @@ -1496,11 +1578,9 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory id: extract run: | @@ -1599,6 +1679,7 @@ jobs: needs: - common - seed-apt-cache + - seed-esp8266-native-cache - determine-jobs - ci-custom - pylint @@ -1614,6 +1695,7 @@ jobs: - clang-tidy-esp32-variants - test-build-components-split - test-esp32-platformio + - test-esp8266-native - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aab3dea592c..da02a5dd09f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index e09e9bf2d17..74ac5fe05a3 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 + uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0dee8165c5..efccad9ad96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to docker hub uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index aa31094f81d..43b7e6663d6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main + uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: @@ -33,7 +33,7 @@ jobs: and will be closed if no further activity occurs within 7 days. If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev + to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. Thank you for your contribution! diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml new file mode 100644 index 00000000000..5599691ed19 --- /dev/null +++ b/.github/workflows/sync-dependency-versions.yml @@ -0,0 +1,94 @@ +# Keeps pre-commit hook revs in sync with the requirements files. +# +# Dependabot only bumps the pins in requirements*.txt. Some of those tools +# are pinned again as hook revs in .pre-commit-config.yaml. This workflow +# runs script/sync_dependency_versions.py against the pull request branch +# and pushes a commit with the revs updated. + +name: Sync dependency versions + +on: + # pull_request_target rather than pull_request so the App secret is + # available on Dependabot pull requests (pull_request runs opened by + # Dependabot only see Dependabot secrets). The job below only touches + # branches in this repository and only ever executes the script from the + # base branch checkout, so fork code never runs with the token. + pull_request_target: + types: [opened, synchronize, reopened] + paths: + - requirements_dev.txt + - requirements_test.txt + - .pre-commit-config.yaml + - script/sync_dependency_versions.py + +# The push to the pull request branch uses the App token minted below, so +# the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync pinned versions + runs-on: ubuntu-latest + # Same-repository branches only: a push to a fork is not possible with + # this token, and it keeps untrusted heads out of a privileged job. + if: >- + github.repository == 'esphome/esphome' + && github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # A push made with the workflow's own GITHUB_TOKEN would not start + # CI on the new commit; a push with the App token does. + permission-contents: write # git push of the sync commit to the pull request branch + + - name: Check out base branch + # Provides the script that runs below. Deliberately the base branch + # so the pull request cannot change what executes here. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Check out pull request branch + # No allow-unsafe-pr-checkout here on purpose: checkout v7 only + # refuses heads that live in a different repository, and the job + # condition above already limits runs to same-repository branches. + # Leaving it off keeps that refusal as a backstop for fork heads. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + path: pull-request + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install yamlrocks + # The script edits YAML through yamlrocks. Take the pin from the + # base branch requirements so this workflow has no copy of its own. + run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)" + + - name: Sync pinned versions + run: python script/sync_dependency_versions.py --root pull-request + + - name: Push changes + working-directory: pull-request + run: | + if git diff --quiet; then + echo "All pinned versions already match the requirements files." + exit 0 + fi + git config user.name "esphome[bot]" + git config user.email "115708604+esphome[bot]@users.noreply.github.com" + git commit -am "Sync pinned tool versions with requirements files" + git push diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9100064176c..a0725374f92 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ea799aa4de..48c0d1d6fd4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks - ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit @@ -11,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.3 + rev: v0.16.9 hooks: # Run the linter. - id: ruff @@ -19,7 +18,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 + rev: 7.4.1 hooks: - id: flake8 additional_dependencies: @@ -42,7 +41,7 @@ repos: - id: pyupgrade args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint exclude: ^(\.clang-format|\.clang-tidy)$ diff --git a/AGENTS.md b/AGENTS.md index 15b92c4deb7..6a60fc47b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,25 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or + `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, + `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, + then make one call per key, even when there is only one. A call creates the entity only when its key + is configured, passes it to the setter and returns it (or `None`); extra arguments such as + `min_value` or `options` go on the call. Always name the setter explicitly on the object that owns + it, never with `getattr` and an f-string, and keep that variable short (`var` for the component + itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose + setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. + ```python + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + buttons = button.sub_buttons(config, parent=var) + await buttons(CONF_RESTART, var.set_restart_button) + ``` + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). @@ -431,7 +450,31 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + Register it without writing a builder: + ```python + automation.register_simple_action( + "my_component.do_something", MyAction, schema, synchronous=True + ) + ``` + The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a + no-argument constructor, `register_parented_action` for a class deriving from `Parented`, and + the `@automation.register_action(...)` decorator only when the builder must also set fields. + + Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + + **Actions that only forward templatable values to their parent need no C++ class.** Register them + with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape. + ```python + automation.register_apply_action( + "my_component.set_gains", + schema, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + ) + ``` + The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover + the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with + `cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values. * **Conditions:** ```cpp @@ -443,7 +486,21 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; + `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. + + **Conditions that only test their parent need no C++ class either.** Register them with + `register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares + against config values. + ```python + automation.register_apply_condition("my_component.is_active", schema, "is_active()") + automation.register_apply_condition( + "my_component.state_is", + schema, + automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)), + ) + ``` + `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. @@ -629,6 +686,9 @@ file does, and it is the authority when they disagree. The most useful starting _request_listener_slot() cg.add(hub.register_listener(var)) ``` + When several instances each own a list declared at the same size (one per hub of a + `MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`; + the define is then the largest count any one key requested instead of the total. ```cpp #ifdef MY_COMPONENT_LISTENER_COUNT void register_listener(MyComponentListener *listener); @@ -696,7 +756,9 @@ file does, and it is the authority when they disagree. The most useful starting 6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. - 7. **Detection:** Look for these patterns in compiler output: + 7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`. + + 8. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) @@ -840,7 +902,7 @@ file does, and it is the authority when they disagree. The most useful starting cv.rename_key( CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" ), - cv.Schema({ ... }), + cv.Schema({...}), ) ``` For other deprecations, warn manually during validation: diff --git a/CODEOWNERS b/CODEOWNERS index f91bc00ae52..ff2b6eb3275 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow @@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke +esphome/components/cdc_acm_uart/* @kbx81 +esphome/components/cdc_acm_uart/bridge/* @kbx81 esphome/components/ch422g/* @clydebarrow @jesterret esphome/components/ch423/* @dwmw2 esphome/components/chsc6x/* @kkosik20 @@ -122,6 +125,7 @@ esphome/components/combination/* @Cat-Ion @kahrendt esphome/components/const/* @esphome/core esphome/components/coolix/* @glmnet esphome/components/copy/* @OttoWinter +esphome/components/counter/* @clydebarrow esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger @@ -179,7 +183,6 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 -esphome/components/esp32_improv/* @jesserockz esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -263,8 +266,10 @@ esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 +esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core +esphome/components/improv_ble/* @jesserockz esphome/components/improv_serial/* @esphome/core esphome/components/ina226/* @latonita @Sergio303 esphome/components/ina260/* @mreditor97 @@ -418,6 +423,7 @@ esphome/components/pn7150_i2c/* @jesserockz @kbx81 esphome/components/pn7160/* @jesserockz @kbx81 esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 +esphome/components/pn71xx/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/provisioning/* @esphome/core @@ -425,6 +431,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/pzem6l24/* @nuttytree esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje @@ -453,6 +460,7 @@ esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco +esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/scd4x/* @martgras @sjtrny @@ -474,6 +482,7 @@ esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/switch/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core @@ -542,7 +551,9 @@ esphome/components/sx126x/* @swoboda1337 esphome/components/sx127x/* @swoboda1337 esphome/components/sy6970/* @linkedupbits esphome/components/syslog/* @clydebarrow +esphome/components/systa_bus/* @Mat931 esphome/components/t6615/* @tylermenezes +esphome/components/tas2780/* @remcom esphome/components/tc74/* @sethgirvan esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski @@ -555,6 +566,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/tfluna/* @candrews esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core @@ -586,6 +598,7 @@ esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow +esphome/components/uart_mux/* @kbx81 esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli @@ -630,6 +643,7 @@ esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier +esphome/components/xiaomi_body_scale/* @dckiller51 esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 esphome/components/xiaomi_lywsd03mmc/* @ahpohl diff --git a/docker/Dockerfile b/docker/Dockerfile index da76ab7b6a7..4c82b8eba9a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,16 +22,13 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.17.2 RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ && mkdir -p /piolibs -COPY script/platformio_install_deps.py platformio.ini / -RUN /platformio_install_deps.py /platformio.ini --libraries - ARG BUILD_VERSION LABEL \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index c88a78f97e1..65d8fd61b55 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,10 +21,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native toolchain installs on the persistent cache root, not the -# container's ephemeral user cache dir (re-downloaded on every restart). +# Keep the native toolchain installs and compiler caches on the persistent +# cache root, not the container's user cache dir: it is lost on every +# restart, and not writable when the container runs as a non-root user. export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf" +export ESPHOME_ARDUINO8266_PREFIX="$(dirname "${pio_cache_base}")/arduino8266" +export ESPHOME_HOST_PREFIX="$(dirname "${pio_cache_base}")/host" +export ESPHOME_PLATFORMIO_CCACHE_DIR="$(dirname "${pio_cache_base}")/platformio-ccache" # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index bb36cfcdb4f..5b0ff67909e 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -4,7 +4,6 @@ # Home Assistant Add-on: ESPHome # Sends discovery information to Home Assistant. # ============================================================================== -declare config declare port # We only disable it when disabled explicitly @@ -19,14 +18,17 @@ port=$(bashio::addon.ingress_port) # Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 -config=$(\ - bashio::var.json \ - host "127.0.0.1" \ - port "^${port}" \ -) +# Send one discovery message; the config is a JSON string built with bashio::var.json. +send_discovery() { + local service=$1 + local config=$2 + if bashio::discovery "${service}" "${config}" > /dev/null; then + bashio::log.info "Successfully send ${service} discovery information to Home Assistant." + else + bashio::log.error "${service} discovery message to Home Assistant failed!" + fi +} -if bashio::discovery "esphome" "${config}" > /dev/null; then - bashio::log.info "Successfully send discovery information to Home Assistant." -else - bashio::log.error "Discovery message to Home Assistant failed!" -fi +send_discovery "esphome" "$(bashio::var.json host "127.0.0.1" port "^${port}")" +# The Device Builder MCP server, consumed by Home Assistant's mcp integration. +send_discovery "mcp" "$(bashio::var.json url "http://127.0.0.1:${port}/api/mcp")" diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index 20fada5f130..dd7f1bddc2e 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,10 +15,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native toolchain installs on the persistent /data volume, not the -# container's ephemeral user cache dir (wiped on every add-on update/restart). +# Keep the native toolchain installs and compiler caches on the persistent +# /data volume, not the container's ephemeral user cache dir (wiped on every +# add-on update/restart). export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf +export ESPHOME_ARDUINO8266_PREFIX=/data/cache/arduino8266 +export ESPHOME_HOST_PREFIX=/data/cache/host +export ESPHOME_PLATFORMIO_CCACHE_DIR=/data/cache/platformio-ccache if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml index 138aa9e282c..f05e400c532 100644 --- a/docker/test_configs/bk72xx-arduino.yaml +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -5,3 +5,15 @@ bk72xx: board: generic-bk7231n-qfn32-tuya logger: + +wifi: + ssid: MySSID + password: password1 + ap: + +# mqtt and captive_portal together pull in AsyncTCP and ESPAsyncWebServer; +# a stray ESP32 AsyncTCP copy on the library search path breaks this build +captive_portal: + +mqtt: + broker: 192.168.178.84 diff --git a/docker/test_configs/esp8266-arduino-native.yaml b/docker/test_configs/esp8266-arduino-native.yaml new file mode 100644 index 00000000000..99eab33ecb0 --- /dev/null +++ b/docker/test_configs/esp8266-arduino-native.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-esp8266-native + +esp8266: + board: d1_mini + toolchain: arduino + +logger: diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml index 80b52260e4d..5a80fd5b1f1 100644 --- a/docker/test_configs/esp8266-arduino.yaml +++ b/docker/test_configs/esp8266-arduino.yaml @@ -3,5 +3,7 @@ esphome: esp8266: board: d1_mini + # The PlatformIO path stays covered whatever the default is + toolchain: platformio logger: diff --git a/esphome/__main__.py b/esphome/__main__.py index 30e97f55eb4..49fc7a3020f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,8 +16,8 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks +from esphome.build_helpers.native import analysis_backend, native_backend from esphome.const import ( - ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, @@ -35,7 +35,6 @@ from esphome.const import ( CONF_LOGGER, CONF_MDNS, CONF_MQTT, - CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, @@ -61,6 +60,7 @@ from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType from esphome.upload_targets import PortType, get_port_type from esphome.util import ( + ESPHOME_COMMAND, PICOTOOL_PACKAGE, FlashImage, detect_rp2040_bootsel, @@ -84,7 +84,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] # Maximum buffer size for serial log reading to prevent unbounded memory growth SERIAL_BUFFER_MAX_SIZE = 65536 @@ -817,7 +816,9 @@ def write_cpp_file() -> int: from esphome.build_gen import espidf espidf.write_project() - else: + elif not CORE.using_native_toolchain: + # Other native builds generate their project at compile time; + # never write a platformio.ini for them from esphome.build_gen import platformio platformio.write_project() @@ -835,7 +836,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: # Keep this here, NOT in codegen: config-hash and --only-generate must keep # working on machines that cannot run the toolchain. - if CORE.is_esp8266: + if CORE.is_esp8266 and CORE.using_toolchain_platformio: from esphome.components.esp8266 import check_rosetta check_rosetta() @@ -859,20 +860,14 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() - from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS + from esphome.build_helpers.idedata import warn_if_idedata_missing - try: - if toolchain.get_idedata() is None: - _LOGGER.warning("No idedata was generated for this build") - except IDEDATA_BEST_EFFORT_ERRORS as err: - # 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) + warn_if_idedata_missing(toolchain.get_idedata) + elif CORE.using_native_toolchain: + raise EsphomeError( + f"Toolchain '{CORE.toolchain.value}' resolved but no platform " + "backend claimed the build" + ) else: from esphome.platformio import toolchain @@ -975,12 +970,15 @@ def upload_using_esptool( if file is not None: flash_images = [FlashImage(path=file, offset="0x0")] - elif CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - flash_images = [ - FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0") - ] + elif (native := native_backend()) is not None: + # Every native backend supplies its own 0x0 flash image (bootloader + # and partitions included where the target needs them) + image = native.get_factory_firmware_path() + if not image.is_file(): + raise EsphomeError( + f"{image} does not exist; compile the configuration first" + ) + flash_images = [FlashImage(path=image, offset="0x0")] else: from esphome.platformio import toolchain @@ -1343,8 +1341,12 @@ def _upload_via_native_api( # fall back to a plaintext upload noise_psk = None plaintext_fallback = False + allow_plaintext_upload = False if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) + allow_plaintext_upload = bool( + encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD) + ) if not noise_psk: raise EsphomeError( "OTA encryption is configured but no key was resolved; " @@ -1395,6 +1397,7 @@ def _upload_via_native_api( ota_type, noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) @@ -1709,26 +1712,12 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: if exit_code != 0: return exit_code if CORE.is_host: - _LOGGER.info( - "Successfully compiled program to path '%s'", _host_program_path(config) - ) + _LOGGER.info("Successfully compiled program to path '%s'", CORE.firmware_bin) else: _LOGGER.info("Successfully compiled program.") return 0 -def _host_program_path(config: ConfigType) -> str: - """Return the compiled host ELF path.""" - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - return str(toolchain.get_elf_path()) - from esphome.platformio.toolchain import get_idedata - - # Memoized by compile_program's own call; this is a dict lookup - return str(get_idedata(config).firmware_elf_path) - - def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( @@ -1773,7 +1762,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code _LOGGER.info("Successfully compiled program.") if CORE.is_host: - program_path = _host_program_path(config) + program_path = str(CORE.firmware_bin) _LOGGER.info("Running program from path '%s'", program_path) return run_external_process(program_path) @@ -1965,12 +1954,12 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json - 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 + native_toolchain = native_backend() - idedata = espidf_toolchain.get_idedata() + if native_toolchain is not None: + # 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: _LOGGER.error( "No idedata available; compile the configuration first", @@ -2009,6 +1998,22 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.cli import MemoryAnalyzerCLI from esphome.analyze_memory.ram_strings import RamStringsAnalyzer + # Refuse an unsupported toolchain before paying for a full compile + analysis_toolchain = analysis_backend() + if analysis_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 + if ( + check_supported := getattr(analysis_toolchain, "check_analysis_supported", None) + ) is not None: + # Raises with the reason; before the compile, not after it + check_supported() + # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) if exit_code != 0: @@ -2020,13 +2025,30 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain + if analysis_toolchain is not None: + objdump = analysis_toolchain.get_objdump_path() + readelf = analysis_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 + _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) - objdump_path = str(toolchain.get_objdump_path()) - readelf_path = str(toolchain.get_readelf_path()) - - firmware_elf = toolchain.get_elf_path() + firmware_elf = analysis_toolchain.get_elf_path() + if not firmware_elf.is_file(): + # The analyzer swallows tool failures, so a missing ELF would + # produce an exit-0 zeroed report + _LOGGER.error( + "%s is missing; compile the configuration first", firmware_elf + ) + return 1 else: from esphome.platformio import toolchain @@ -2080,155 +2102,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome import yaml_util + from esphome.cli.rename import command_rename as run - new_name = args.name - for c in new_name: - if c not in ALLOWED_NAME_CHARS: - safe_print( - color( - AnsiFore.BOLD_RED, - f"'{c}' is an invalid character for names. Valid characters are: " - f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)", - ) - ) - return 1 - # Load existing yaml file - raw_contents = CORE.config_path.read_text(encoding="utf-8") - - yaml = yaml_util.load_yaml(CORE.config_path) - if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - safe_print( - color( - AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." - ) - ) - return 1 - old_name = yaml[CONF_ESPHOME][CONF_NAME] - match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name) - if match is None: - # Only swap the ``name:`` line that sits directly under the - # top-level ``esphome:`` block. A naked ``re.sub`` would - # also clobber any other ``name:`` line whose value happens - # to match (e.g. a sensor / output / wifi entry sharing the - # device's hostname), silently rewriting unrelated user - # configuration. The pattern anchors: - # - at the start of the line so ``friendly_name:``, - # ``device_name:`` etc. don't match the trailing ``name:`` - # substring; and - # - at the end of the value (lookahead for whitespace + - # comment + EOL) so ``old_name`` doesn't match as a - # prefix of a longer value (``kitchen`` vs ``kitchen2``). - name_pattern = re.compile( - rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))" - ) - out_lines: list[str] = [] - in_esphome_block = False - for line in raw_contents.splitlines(keepends=True): - if line and not line[0].isspace() and line.strip(): - in_esphome_block = line.lstrip().startswith("esphome:") - out_lines.append(line) - continue - if in_esphome_block: - line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1) - out_lines.append(line) - new_raw = "".join(out_lines) - else: - old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)] - if ( - len( - re.findall( - rf"^\s+{match.group(1)}:\s+[\"']?{old_name}[\"']?", - raw_contents, - flags=re.MULTILINE, - ) - ) - > 1 - ): - safe_print( - color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") - ) - return 1 - - new_raw = re.sub( - rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?", - f'\\1: "{new_name}"', - raw_contents, - flags=re.MULTILINE, - ) - - # ``new_name == old_name`` (after substitution resolution) is - # a no-op rewrite that would still queue a pointless re-flash. - # Catch it before the path-equality check below — covers the - # case where the config filename doesn't match the device name - # (e.g. ``weird-file.yaml`` whose ``esphome.name`` is - # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` - # would otherwise just re-flash the same hostname). - if new_name == old_name: - safe_print( - color( - AnsiFore.BOLD_RED, - f"'{new_name}' is already the device's name.", - ) - ) - return 1 - - new_path: Path = CORE.config_dir / (new_name + ".yaml") - if new_path.resolve() == CORE.config_path.resolve(): - safe_print( - color( - AnsiFore.BOLD_RED, - f"'{new_name}' is already the device's name.", - ) - ) - return 1 - if new_path.exists(): - safe_print( - color( - AnsiFore.BOLD_RED, - f"Cannot rename: {new_path} already exists. " - "Refusing to overwrite an existing configuration.", - ) - ) - return 1 - safe_print( - f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" - ) - print() - - new_path.write_text(new_raw, encoding="utf-8") - - rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) - if rc != 0: - safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) - new_path.unlink() - return 1 - - cli_args = [ - "run", - str(new_path), - "--no-logs", - "--device", - CORE.address, - ] - - if args.dashboard: - cli_args.insert(0, "--dashboard") - - try: - rc = run_external_process(*ESPHOME_COMMAND, *cli_args) - except KeyboardInterrupt: - rc = 1 - if rc != 0: - new_path.unlink() - return 1 - - if CORE.config_path != new_path: - CORE.config_path.unlink() - - safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) - print() - return 0 + return run(args, config) PRE_CONFIG_ACTIONS = { @@ -2343,7 +2219,8 @@ def parse_args(argv): metavar="{" + ",".join(t.value for t in Toolchain) + "}", help=( "Select toolchain for compiling. Overrides '.toolchain' in YAML. " - f"Default: {Toolchain.PLATFORMIO.value}." + "Default: the platform's native toolchain where it has one, else " + f"{Toolchain.PLATFORMIO.value}." ), ) diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 4fb3689818b..8d7107b782d 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -148,6 +148,13 @@ class AddressCache: continue hostname, ips = arg.split("=", 1) # Normalize hostname for consistent lookups - normalized = normalize_hostname(hostname) - cache[normalized] = [ip.strip() for ip in ips.split(",")] + normalized = normalize_hostname(hostname.strip()) + addresses = [ip for value in ips.split(",") if (ip := value.strip())] + if not normalized or not addresses: + _LOGGER.warning( + "Invalid cache entry: %s (hostname and at least one address are required)", + arg, + ) + continue + cache[normalized] = addresses return cache diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 19041ac8071..7d8195a17b1 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None: """ name = build_path.name for candidate in ( - # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # Native ESP-IDF: the build writes build/.elf, which ESPHome copies # to build/firmware.elf (see espidf.toolchain.create_elf_copy) build_path / "build" / "firmware.elf", # PlatformIO @@ -68,12 +68,16 @@ def idedata_candidates(build_path: Path) -> list[Path]: The candidate idedata JSON paths, most specific first """ name = build_path.name + data_dir = build_path.parent.parent / "idedata" + # Native backends suffix the cache by toolchain (.arduino.json) + suffixed = sorted(data_dir.glob(f"{name}.*.json")) if data_dir.is_dir() else [] return [ # In .pioenvs for test builds build_path / ".pioenvs" / name / "idedata.json", # Both toolchains cache it in the data dir, which holds this build dir: # /idedata/.json next to /build/ build_path.parent.parent / "idedata" / f"{name}.json", + *suffixed, # Regular builds, invoked from the config dir or from anywhere Path.cwd() / ".esphome" / "idedata" / f"{name}.json", Path.home() / ".esphome" / "idedata" / f"{name}.json", diff --git a/esphome/api_client.py b/esphome/api_client.py index fb41075de82..2b93b4790f7 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -23,9 +23,7 @@ from esphome.util import safe_print if TYPE_CHECKING: from collections.abc import Callable - from aioesphomeapi.api_pb2 import ( - SubscribeLogsResponse, # pylint: disable=no-name-in-module - ) + from aioesphomeapi.api_pb2 import SubscribeLogsResponse # pylint: disable=no-name-in-module _LOGGER = logging.getLogger(__name__) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index e224e625897..143b6d23a07 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -2,7 +2,9 @@ Bundled names build straight from the framework tree; everything else goes through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each -library builds its own archive; all include dirs join one global path. +library builds its own archive; all include dirs join one global path. The +host build reuses it without a framework tree: nothing is bundled there and +every name resolves from the registry. Deviations from PlatformIO: flat-layout libraries get the recursive default source filter; ``dot_a_linkage`` is honored; bundled libraries never run a @@ -342,13 +344,24 @@ def _check_unfulfilled_provides( def resolve_libraries( - framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str + framework_path: Path | None, + *, + pio_platform: str, + board_mcu: str, + cache_key: str, + framework: str | None = "arduino", + manifest_optional: bool = False, ) -> list[ArduinoLibrary]: """Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`. ``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the - shared converter's download cache. + shared converter's download cache. ``framework`` is the manifest + framework token the compatibility check warns about; None skips it. + A None ``framework_path`` means no core-bundled libraries exist (the + host build): every name resolves from the registry. + ``manifest_optional`` accepts libraries without a manifest, built with + PlatformIO's default layout. The returned list is not topologically sorted, so the caller must link the archives inside one ``--start-group``/``--end-group`` pair (the @@ -359,18 +372,22 @@ def resolve_libraries( # PlatformIO's lib_ignore covers framework-bundled libraries too; the # shared converter only filters the registry/git ones. lib_ignore = lib_ignore_set() - # Exact directory names keep membership case-sensitive everywhere - # (an is_dir() probe would match "wire" on macOS/Windows and build - # the bundled Wire twice) - libraries_dir = framework_path / "libraries" - if not libraries_dir.is_dir(): - # A registry fallback would fail later with a misleading - # package-not-found error per bundled name - raise EsphomeError( - f"{libraries_dir} is missing; the framework install may be " - "incomplete (run 'esphome clean-all')" + bundled_dir_names: frozenset[str] = frozenset() + if framework_path is not None: + # Exact directory names keep membership case-sensitive everywhere + # (an is_dir() probe would match "wire" on macOS/Windows and build + # the bundled Wire twice) + libraries_dir = framework_path / "libraries" + if not libraries_dir.is_dir(): + # A registry fallback would fail later with a misleading + # package-not-found error per bundled name + raise EsphomeError( + f"{libraries_dir} is missing; the framework install may be " + "incomplete (run 'esphome clean-all')" + ) + bundled_dir_names = frozenset( + p.name for p in libraries_dir.iterdir() if p.is_dir() ) - bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir()) def _provided(name: object) -> bool: return _is_safe_library_name(name) and name in bundled_dir_names @@ -497,12 +514,13 @@ def resolve_libraries( backend = LibraryBackend( platform=pio_platform, - framework="arduino", + framework=framework, emit=_emit, cache_key=cache_key, # The walk must not resolve bundled names from the registry; # _add_bundled_dependencies adds them after emit provides=_provided, + manifest_optional=manifest_optional, ) if external: convert_libraries(external, backend) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 663002b3b14..b731b746751 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -3,12 +3,12 @@ Artifacts land in a machine-global cache (shared across projects, like the ESP-IDF install in ``esphome.espidf.framework``): - /arduino8266/frameworks// framework-arduinoespressif8266 - /arduino8266/toolchains// toolchain-xtensa (gcc 10.3) + /arduino8266/frameworks// the Arduino core + /arduino8266/toolchains// xtensa-lx106-elf gcc 10.3 -Packages come from the PlatformIO registry (identical bits to the PlatformIO -backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes -from PATH or the ninja PyPI wheel. +Both come from esphome-libs releases pinned below; +``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs, with ``{VERSION}`` +standing for the release tag. ninja comes from PATH or the ninja PyPI wheel. """ from __future__ import annotations @@ -17,18 +17,74 @@ import os from pathlib import Path from typing import NamedTuple -from esphome.build_helpers.ccache import ccache_defaults_env +from esphome.build_helpers.ccache import ccache_env from esphome.build_helpers.ninja import find_ninja from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path from esphome.core import EsphomeError, Version from esphome.framework_helpers import str_to_lst_of_str -from esphome.platformio.registry import install_package, prefetch_packages +from esphome.platformio.registry import ( + Download, + PackageSpec, + Resolver, + get_systype, + install_packages, + prefetch_packages, +) -FRAMEWORK_PACKAGE = "framework-arduinoespressif8266" -TOOLCHAIN_PACKAGE = "toolchain-xtensa" +FRAMEWORK_PACKAGE = "arduino-esp8266" +_FRAMEWORK_RELEASES = "https://github.com/esphome-libs/arduino-esp8266/releases/" + + +class FrameworkRelease(NamedTuple): + tag: str + sha256: str + size: int + + def download(self) -> Download: + archive = f"{FRAMEWORK_PACKAGE}-{self.tag}.tar.gz" + url = f"{_FRAMEWORK_RELEASES}download/{self.tag}/{archive}" + return Download(url, self.sha256, self.size) + + +# Arduino core version -> its build in esphome-libs/arduino-esp8266 +FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { + Version(3, 1, 2): FrameworkRelease( + "3.1.2-esphome.1", + "e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582", + 37189311, + ), +} + +TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf" # gcc 10.3, the toolchain Arduino core 3.x builds with; the build # generator's compile flags are tuned to it. -TOOLCHAIN_VERSION = "2.100300.220621" +TOOLCHAIN_VERSION = "10.3.0-esphome.2" +_TOOLCHAIN_RELEASES = ( + "https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/" +) +# Registry system tag -> (sha256, size) of that host's archive +TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = { + "darwin_arm64": ( + "849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201", + 60830321, + ), + "darwin_x86_64": ( + "ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897", + 64149487, + ), + "linux_aarch64": ( + "60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6", + 67573397, + ), + "linux_x86_64": ( + "1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4", + 68431336, + ), + "windows_amd64": ( + "af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b", + 67664137, + ), +} ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "") @@ -44,36 +100,40 @@ def get_arduino8266_tools_path() -> Path: return tools_cache_path(*ARDUINO8266_TOOLS_CACHE) -# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0 -MIN_FRAMEWORK_VERSION = Version(3, 1, 1) - - -def framework_package_version(ver: Version) -> str: - """Map an Arduino core version to its registry package version (3.1.2 -> - 3.30102.0; the leading 3 is the package major). - - Exact registry names for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION. - """ - if ver.major > 3: +def framework_release(version: Version) -> FrameworkRelease: + if (release := FRAMEWORK_RELEASES.get(version)) is None: raise EsphomeError( - f"Arduino core {ver} is not supported yet; " - "the newest known core series is 3.x" + f"'toolchain: arduino' has no build of Arduino core {version}; " + f"available: {', '.join(str(v) for v in FRAMEWORK_RELEASES)}. " + "Use one of those or 'toolchain: platformio'" ) - if ver.major < 3: - raise EsphomeError( - f"Arduino core {ver} is not supported; ESPHome requires core 3.x" - ) - return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + return release -def get_framework_path(package_version: str) -> Path: - return get_arduino8266_tools_path() / "frameworks" / package_version +def get_framework_path(tag: str) -> Path: + return get_arduino8266_tools_path() / "frameworks" / tag def get_toolchain_path() -> Path: return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION +def toolchain_download() -> Download: + """The toolchain archive for the current host.""" + systype = get_systype() + if (build := TOOLCHAIN_BUILDS.get(systype)) is None: + raise EsphomeError( + f"There is no ESP8266 toolchain for this system ({systype}); " + f"supported systems are {', '.join(sorted(TOOLCHAIN_BUILDS))}. " + "Either set 'toolchain: platformio' under 'esp8266:', or point " + "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS at a toolchain archive" + ) + sha256, size = build + archive = f"{TOOLCHAIN_PACKAGE}-{TOOLCHAIN_VERSION}-{systype}.tar.gz" + url = f"{_TOOLCHAIN_RELEASES}download/{TOOLCHAIN_VERSION}/{archive}" + return Download(url, sha256, size) + + class InstalledPaths(NamedTuple): """Locations of the installed framework, toolchain, and ninja binary.""" @@ -84,29 +144,22 @@ class InstalledPaths(NamedTuple): def check_and_install(framework_version: Version) -> InstalledPaths: """Ensure framework, toolchain, and ninja are installed; return their paths.""" - if framework_version < MIN_FRAMEWORK_VERSION: - # Config validation enforces this too; keep the module honest when - # called directly. - raise EsphomeError( - f"The native toolchain requires the Arduino core " - f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}" - ) + release = framework_release(framework_version) # Probe the cheap local dependency before ~110 MB of downloads ninja_path = find_ninja() - package_version = framework_package_version(framework_version) - framework_path = get_framework_path(package_version) + framework_path = get_framework_path(release.tag) downloads_dir = get_arduino8266_tools_path() / "downloads" toolchain_path = get_toolchain_path() # One spec per package: the prefetch and the installs must agree specs = ( - ( + PackageSpec( FRAMEWORK_PACKAGE, - package_version, + release.tag, framework_path, ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, ("cores/esp8266", "tools/sdk", "libraries"), ), - ( + PackageSpec( TOOLCHAIN_PACKAGE, TOOLCHAIN_VERSION, toolchain_path, @@ -115,10 +168,17 @@ def check_and_install(framework_version: Version) -> InstalledPaths: ("bin", "xtensa-lx106-elf"), ), ) - # Fetch both archives at once; the installs below verify and extract - prefetch_packages([spec[:4] for spec in specs], downloads_dir) - for name, version, dest, mirrors, expect in specs: - install_package(name, version, dest, mirrors, downloads_dir, expect=expect) + # Resolved only when a download is needed, so an installed toolchain + # keeps working on a host without a build; a mirror override wins + resolvers: dict[str, Resolver] = {} + if not ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS: + resolvers[FRAMEWORK_PACKAGE] = release.download + if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS: + resolvers[TOOLCHAIN_PACKAGE] = toolchain_download + # Fetch both archives at once; the installs verify and extract them. + # One spec list for both, so the two phases cannot drift. + prefetch_packages(specs, downloads_dir, resolvers) + install_packages(specs, downloads_dir, resolvers) return InstalledPaths( framework=framework_path, toolchain=toolchain_path, ninja=ninja_path ) @@ -143,17 +203,5 @@ def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]: *filter(None, env.get("PATH", "").split(os.pathsep)), ] env["PATH"] = os.pathsep.join(parts) - env.update(ccache_env(ccache)) + env.update(ccache_env(ccache, ARDUINO8266_TOOLS_CACHE)) return env - - -def ccache_env(ccache: str | None) -> dict[str, str]: - """Return ccache settings for the build subprocess (not os.environ). - - ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None - when disabled. Values the user already set in the environment are - respected. - """ - if ccache is None: - return {} - return ccache_defaults_env(get_arduino8266_tools_path() / "ccache") diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py new file mode 100644 index 00000000000..5bc1889a21b --- /dev/null +++ b/esphome/arduino8266/toolchain.py @@ -0,0 +1,212 @@ +"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent).""" + +from __future__ import annotations + +import logging +from pathlib import Path +import subprocess +from typing import TYPE_CHECKING + +from esphome.build_helpers.ccache import resolve_absolute_ccache_path +from esphome.build_helpers.native import warn_ignored_platformio_options +from esphome.build_helpers.ninja import refresh_compile_commands +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_FRAMEWORK_VERSION, +) +from esphome.core import CORE +from esphome.types import ConfigType + +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + +_LOGGER = logging.getLogger(__name__) + +# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest) +_MAX_RAM_SIZE = 81920 + + +_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: + # Imported here, not at module scope: the serial upload/logs fast path + # resolves this module for its artifact paths alone, and framework + # pulls in the whole package-download stack + from esphome.arduino8266 import framework + + 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.arduino8266 import framework + from esphome.build_gen import arduino8266 as build_gen + from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS + + warn_ignored_platformio_options(NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS) + paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) + # Resolved once: the probe is not free and three consumers need it + ccache = resolve_absolute_ccache_path() + ninja_changed = build_gen.write_project(paths, ccache) + + build_dir = get_build_dir() + env = framework.get_build_env(paths.toolchain, ccache) + refresh_compile_commands(paths.ninja, build_dir, env, ninja_changed) + + 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: a generator defect that drops them fails loudly + # instead of a green no-op run leaving stale artifacts in place + targets = ["firmware.factory.bin", "firmware.ota.bin"] + cmd += targets + + # cwd, not -C: drops ninja's "Entering directory" banner + _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 missing targets; existence covers a rule that + # ran but wrote elsewhere + 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): + # Cause already warned; name the consequence for 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 _parse_app_size(build_dir: Path, paths: 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: + # A corrupt script degrades the same way, never aborts the build + _LOGGER.warning("Cannot read linker script for the Flash summary: %s", err) + return None + if not (app_size := segment_length(ld_text, "irom0_0_seg")): + _LOGGER.warning("No usable irom0_0_seg in %s; skipping Flash summary", ld_path) + return None + return app_size + + +def _print_size_summary(build_dir: Path, paths: InstalledPaths) -> bool: + """Print the RAM/Flash lines ``ci_memory_impact_extract.py`` parses; + False when skipped.""" + from esphome.arduino8266.framework import toolchain_tool + from esphome.build_helpers.size_summary import print_size_line + + try: + result = subprocess.run( + [ + str(toolchain_tool(paths.toolchain, "size")), + "-A", + "-d", + str(build_dir / "firmware.elf"), + ], + capture_output=True, + text=True, + check=True, + close_fds=False, + ) + except (OSError, subprocess.CalledProcessError) 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 + # -d prints decimal sizes; anything else trips the missing-sections guard + sections = { + parts[0]: int(parts[1]) + for line in result.stdout.splitlines() + if (parts := line.split())[:1] and parts[0].startswith(".") and len(parts) >= 2 + if parts[1].isdigit() + } + 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: a RAM line without its + # Flash line would skew CI's memory-impact extraction + 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 + + +def get_idedata(ccache: str | None = None) -> dict | None: + """Derive idedata from the build's compile_commands.json (same + contract as ``espidf.toolchain.get_idedata``).""" + from esphome.build_helpers.idedata import load_or_build_idedata + + # A disabled ccache resolves to None without spawning anything, so + # re-resolving here costs nothing when the caller has no answer + launcher = ccache or resolve_absolute_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(launcher) if launcher else None, + ) diff --git a/esphome/automation.py b/esphome/automation.py index 1689d29c42f..c8d0e4c5743 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,5 +1,8 @@ +from collections.abc import Callable from dataclasses import dataclass, field import logging +import string +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -18,15 +21,17 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, Lambda +from esphome.core import CORE, ID, EsphomeError, Lambda from esphome.cpp_generator import ( + FlashStringLiteral, LambdaExpression, MockObj, MockObjClass, TemplateArgsType, + call_lambda, ) from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType +from esphome.types import ConfigType, SafeExpType from esphome.util import Registry @@ -57,6 +62,7 @@ def maybe_conf(conf, *validators): with cv.remove_prepend_path([conf]): return validator({conf: value}) + validate.inner_schema = validator return validate @@ -102,6 +108,101 @@ def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schem return CONDITION_REGISTRY.register(name, condition_type, schema) +async def _build_with_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(automation_id, template_arg, parent) + + +async def _build_without_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return cg.new_Pvariable(automation_id, template_arg) + + +async def _build_parented( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(automation_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +def register_simple_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes the object named by ``config[CONF_ID]``. + + Use the ``register_action`` decorator instead when the builder must also set fields. + """ + register_action(name, action_type, schema, synchronous=synchronous)( + _build_with_parent + ) + + +def register_simple_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_simple_action``.""" + register_condition(name, condition_type, schema)(_build_with_parent) + + +def register_bare_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes no arguments.""" + register_action(name, action_type, schema, synchronous=synchronous)( + _build_without_parent + ) + + +def register_bare_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_bare_action``.""" + register_condition(name, condition_type, schema)(_build_without_parent) + + +def register_parented_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action deriving from ``Parented``. + + The object is constructed without arguments and ``set_parent()`` receives the object + named by ``config[CONF_ID]``. + """ + register_action(name, action_type, schema, synchronous=synchronous)(_build_parented) + + +def register_parented_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_parented_action``.""" + register_condition(name, condition_type, schema)(_build_parented) + + Action = cg.esphome_ns.class_("Action") Trigger = cg.esphome_ns.class_("Trigger") ACTION_REGISTRY = Registry() @@ -112,6 +213,308 @@ validate_action_list = cv.validate_registry("action", ACTION_REGISTRY) validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) +ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) +ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) + + +def flash_string(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" + if CORE.is_esp8266: + return f"progmem_string({FlashStringLiteral(value)})" + return str(cg.safe_exp(value)) + + +def literal_with_length(config: ConfigType, value: str) -> str: + """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. + + The target compares or copies the bytes in place, so it needs the RAM literal rather than + the PROGMEM rendering on ESP8266, and the length saves a strlen. + """ + return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" + + +@dataclass(frozen=True) +class ApplyCall: + """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. + + Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a + path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use + ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an + id bypasses it. The statement is skipped when none of its keys is set, always emitted when it + has no keys, and a partial set is a config error. + """ + + target: str + args: tuple[tuple[Any, ...], ...] = () + + def __post_init__(self) -> None: + fields = [ + f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None + ] + if any(fields): + raise ValueError( + f"apply target {self.target!r}: only bare {{}} placeholders" + ) + if len(fields) != len(self.args): + raise ValueError( + f"apply target {self.target!r} has {len(fields)} " + f"placeholder(s) for {len(self.args)} config key(s)" + ) + if any(len(arg) not in (2, 3) for arg in self.args): + raise ValueError( + f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" + ) + + @property + def members(self) -> list[tuple[Any, Any, Any]]: + """Each arg as ``(conf_key, type_, const_fn or None)``.""" + return [ + (arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in self.args + ] + + +@dataclass(frozen=True) +class ApplyField: + """One config key forwarded as ``target(value)``, or as statement ``target`` when it has ``{}``. + + Double a literal brace in a template. ``conf_key`` may be a path into nested sections. + ``type_`` may be a C++ type string using ``{parent}`` when the type is only known per + instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp`` + is not the right spelling (unit conversion belongs in the validator); a lambda or an id + bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing. + """ + + conf_key: str | tuple[str, ...] + target: str + type_: SafeExpType + const_fn: Callable[[ConfigType, Any], str] | None = None + + def call(self) -> ApplyCall: + target = self.target if "{}" in self.target else f"{self.target}({{}})" + return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),)) + + +def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any: + if isinstance(key, str): + return config.get(key) + for part in key: + if (config := config.get(part)) is None: + return None + return config + + +def _dict_schema(schema: Any) -> Any: + """The dict-backed cv.Schema inside cv.All and maybe_* wrappers, or None; cv.Any is not inspected.""" + if isinstance(schema, dict): + return cv.Schema(schema) + if isinstance(getattr(schema, "schema", None), dict): + return schema + if isinstance(schema, cv.All): + inner = schema.validators + else: + inner = ( + getattr(schema, "inner_schema", None), + ) # maybe_conf / maybe_simple_value + for candidate in inner: + if candidate is not None and (found := _dict_schema(candidate)) is not None: + return found + return None + + +def _check_key_in_schema( + name: str, schema: Any, conf_key: str | tuple[str, ...] +) -> None: + """Reject a key path the schema does not have; a typo would otherwise be a silent no-op. + + Only dict-backed schemas, also inside cv.All and maybe_* wrappers, can be checked. + """ + for part in (conf_key,) if isinstance(conf_key, str) else conf_key: + if (schema := _dict_schema(schema)) is None: + return + markers = { + getattr(marker, "schema", marker): marker for marker in schema.schema + } + if part not in markers: + raise ValueError(f"{name}: config key {part!r} is not in the schema") + schema = schema.schema[markers[part]] + + +async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: + # Global-scope qualified so a trigger arg named like the id cannot shadow it. + return f"::{await cg.get_variable(config[id_key])}" + + +def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: + # The generated function's parameters; a std::string arg is never copied. + return [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + for t, arg in args + ] + + +def _apply_function( + id_: ID, + return_type: SafeExpType, + template_arg: cg.TemplateArguments, + lambda_args: TemplateArgsType, + statements: list[str], +) -> MockObj: + """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or + ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" + fn = cg.static_function( + f"esphome__{id_.id}__fn", return_type, lambda_args, statements + ) + return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) + + +async def _render_values( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str, + lambda_args: TemplateArgsType, + compare: bool = False, +) -> list[str]: + """Render the argument text of one statement; every key must be present. + + ``compare``: an inlined lambda expression is parenthesized so it binds as a whole + beside an operator. + """ + if any(value is None for value in values): + keys = [key for key, _, _ in members] + raise EsphomeError(f"{name}: {target!r} needs all of {keys}") + exprs: list[str] = [] + for (_, type_, const_fn), value in zip(members, values, strict=True): + if isinstance(value, Lambda): + if isinstance(type_, str): + type_ = cg.RawExpression(type_.format(parent=parent)) + inner = await cg.process_lambda(value, lambda_args, return_type=type_) + expr = call_lambda(inner) + bare = compare and isinstance(expr, cg.RawExpression) + exprs.append(f"({expr})" if bare else str(expr)) + elif isinstance(value, ID): + # Qualified like the parent, so a trigger arg named like the id cannot shadow it. + exprs.append(f"::{await cg.get_variable(value)}") + elif const_fn is not None: + exprs.append(const_fn(config, value)) + else: + exprs.append(str(cg.safe_exp(value))) + return exprs + + +def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> list[Any]: + return [_config_lookup(config, key) for key, _, _ in members] + + +def register_apply_action( + name: str, + schema: cv.Schema, + *fields: ApplyField | ApplyCall, + call: str | None = None, + id_key: str = CONF_ID, +) -> None: + """Register an action that only forwards config values to its parent, with no C++ class. + + Generates one static function with the parent (read from ``id_key``) and constants baked + in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it. + A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. + With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, + and ``apply_call.perform()`` is appended. + """ + # An action stores the value, so a std::string constant stays in flash on ESP8266. + statements_spec = [ + ( + c.target, + [ + (key, t, fn or (flash_string if t is cg.std_string else None)) + for key, t, fn in c.members + ], + ) + for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) + ] + _check_key_in_schema(name, schema, id_key) + for _, members in statements_spec: + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + parent = await _apply_parent(config, id_key) + lambda_args = _apply_lambda_args(args) + receiver = "apply_call." if call else f"{parent}->" + statements: list[str] = [] + for target, members in statements_spec: + values = _apply_values(config, members) + if members and all(value is None for value in values): + continue + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args + ) + statements.append(f"{receiver}{target.format(*exprs)};") + if call: + statements = [ + f"auto apply_call = {parent}->{call}();", + *statements, + "apply_call.perform();", + ] + return _apply_function( + action_id, cg.void, template_arg, lambda_args, statements + ) + + register_action(name, ApplyAction, schema, synchronous=True)(builder) + + +def register_apply_condition( + name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID +) -> None: + """Register a condition that is one expression on its parent, with no C++ class. + + ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an + ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares + against config values, all of which must be present. Write ``== false`` to negate. + String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + Generates one static predicate and an ``ApplyCondition`` templated on it. + """ + call = check if isinstance(check, ApplyCall) else ApplyCall(check) + members = call.members + _check_key_in_schema(name, schema, id_key) + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + parent = await _apply_parent(config, id_key) + lambda_args = _apply_lambda_args(args) + exprs = await _render_values( + name, + call.target, + members, + _apply_values(config, members), + config, + parent, + lambda_args, + compare=True, + ) + return _apply_function( + condition_id, + cg.bool_, + template_arg, + lambda_args, + [f"return {parent}->{call.target.format(*exprs)};"], + ) + + register_condition(name, ApplyCondition, schema)(builder) + def validate_potentially_and_condition(value): if isinstance(value, list): @@ -359,28 +762,15 @@ async def for_condition_to_code( return var -@register_condition( +register_apply_condition( "component.is_idle", - LambdaCondition, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(cg.Component), } ), + "is_idle()", ) -async def component_is_idle_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - lambda_ = await cg.process_lambda( - Lambda(f"return {comp}->is_idle();"), args, return_type=bool - ) - return new_lambda_pvariable( - condition_id, lambda_, StatelessLambdaCondition, template_arg - ) @register_action( @@ -534,44 +924,20 @@ async def lambda_action_to_code( return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg) -@register_action( +register_simple_action( "component.update", UpdateComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) -@register_action( +register_simple_action( "component.suspend", SuspendComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_suspend_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) @register_action( diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py new file mode 100644 index 00000000000..fd198b2e0c7 --- /dev/null +++ b/esphome/build_gen/arduino8266.py @@ -0,0 +1,1191 @@ +"""Native ninja build generator for the ESP8266 Arduino core. + +Transliterates the PlatformIO build spec for the Arduino ESP8266 framework +(``framework-arduinoespressif8266/tools/platformio-build.py`` plus +``platform-espressif8266/builder/main.py``) into a ``build.ninja`` under +``.pioenvs//``. The flag sets, defines, link line, linker-script +generation, and ``elf2bin`` invocation deliberately match what PlatformIO +produces so the binaries stay near-identical between the two toolchains. + +The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK +version, MMU layout, exceptions, waveform phase) keep working: they are read +from the build flags with the same precedence as the PlatformIO builder. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import logging +import os +from pathlib import Path +import re +import shlex +import subprocess +from typing import TYPE_CHECKING, NamedTuple + +from esphome.arduino8266.framework import toolchain_tool +from esphome.build_helpers.idedata import is_joined_include +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) +from esphome.build_helpers.ninja_gen import ( + ar_rule_lines, + collect_sources, + compile_edges, + compile_rule_lines, + library_edges, + pch_edges, + pch_rule_lines, + tool_lines, +) +from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS +from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import ( + BOARDS, + ESP8266_BOARD_BUILD, + board_ld_script, +) +from esphome.components.esp8266.const import ( + BUILD_FLASH_MODES, + KEY_BOARD, + KEY_ESP8266, + KEY_FLASH_SIZE, + KEY_SCANF_FLOAT, +) +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_project_cxx_compile_flags +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.platformio.library import lex_build_flags + +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + +_LOGGER = logging.getLogger(__name__) + +# Always excluded from the core build: ESPHome uses its own native OTA +# backend, so the Arduino Updater (and its 228-byte global) never links. +_CORE_EXCLUDE_ALWAYS = {"Updater.cpp"} +# Excluded when no component called require_waveform(); waveform_stubs.cpp +# supplies the stopWaveform()/_stopPWM() stubs digitalWrite needs. +_CORE_EXCLUDE_WAVEFORM = { + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", +} + +# Values that land unquoted on generated command lines are shape-checked +# against these before use. re.ASCII: a Unicode digit or word character +# (Arabic-Indic numerals) would pass \d/\w and defeat the named error +_MMU_VALUE_RE = re.compile(r"(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*", re.ASCII) +_MMU_HEX_VALUE_RE = re.compile(r"0[xX][0-9a-fA-F]+[uUlL]*", re.ASCII) +# Only these land in the preprocessed script's ``len =`` fields, which +# build_surgery's segment parser reads back as hex; the other MMU_* macros +# (MMU_EXTERNAL_HEAP=128) are consumed by mmu_iram.h and may be decimal +_MMU_SEGMENT_SIZE_NAMES = ("MMU_IRAM_SIZE", "MMU_ICACHE_SIZE") +_BOARD_NAME_RE = re.compile(r"[\w.-]+", re.ASCII) +_F_CPU_RE = re.compile(r"\d+L?", re.ASCII) +_FLASH_LD_NAME_RE = re.compile(r"[\w.-]+\.ld", re.ASCII) + +# Every supported board ships this clock; board_build.f_cpu overrides +_DEFAULT_F_CPU = "80000000L" + +# The SDK linker-script template and the preprocessed copy the build links +# against; the cache stamp and stderr sidecars derive from the output name +_COMMON_LD_HEADER = "eagle.app.v6.common.ld.h" +_COMMON_LD_NAME = "local.eagle.app.v6.common.ld" +# Testing mode shadows the SDK flash ld with a patched copy under this name +_TESTING_LD_PREFIX = "testing_" + +# The recovery hint for a half-extracted or damaged framework cache +_CLEAN_HINT = "run 'esphome clean-all' and retry" + + +def _sdk_ld_dir(framework: Path) -> Path: + return framework / "tools" / "sdk" / "ld" + + +def _apply_surgery(fn, *args: object) -> str: + """Run one build_surgery edit, naming a failed anchor instead of a + traceback (the surgery module raises bare RuntimeError so its + ``.py.script`` twins stay importable without esphome).""" + try: + return fn(*args) + except RuntimeError as err: + raise EsphomeError(str(err)) from err + + +# Every supported board's f_flash is 40 MHz; re-check on a platform bump +# board_flash_mode's closed set, shared with cv.one_of's validation +_FLASH_MODES = frozenset(BUILD_FLASH_MODES) +_FLASH_FREQ_MHZ = 40 + +# From platformio-build.py. Knob suffix -> SDK define; the first entry is +# the default (dicts preserve insertion order). With multiple SDK knobs set +# (a pathological config) ties break by table order, since upstream's +# tie-break depends on define order and is not reproducible here. +_NONOSDK_VERSIONS = { + "SDK22x_190703": "NONOSDK22x_190703", + "SDK221": "NONOSDK221", + "SDK22x_190313": "NONOSDK22x_190313", + "SDK22x_191024": "NONOSDK22x_191024", + "SDK22x_191105": "NONOSDK22x_191105", + "SDK22x_191122": "NONOSDK22x_191122", + "SDK305": "NONOSDK305", +} + + +class _LwipVariant(NamedTuple): + """One lwIP build variant: the defines and the prebuilt library that + was compiled with them.""" + + tcp_mss: int + features: int + ipv6: int + lib: str + + +# Knob define -> variant; first match wins, in insertion order (as in +# platformio-build.py) +_LWIP_VARIANTS = { + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY": _LwipVariant( + 536, 1, 1, "lwip6-536-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH": _LwipVariant( + 1460, 1, 1, "lwip6-1460-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH": _LwipVariant( + 1460, 1, 0, "lwip2-1460-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH": _LwipVariant( + 536, 0, 0, "lwip2-536" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH": _LwipVariant( + 1460, 0, 0, "lwip2-1460" + ), +} +# The default is PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY's variant: upstream +# has no branch for that spelling (it is the else), so any listed knob wins +# over it -- sntp emits LOW_MEMORY while esp8266 always emits +# HIGHER_BANDWIDTH_LOW_FLASH, and the latter must win as under PlatformIO +_LWIP_DEFAULT = _LwipVariant(536, 1, 0, "lwip2-536-feat") + +# Knob define -> MMU_* defines; first match wins, in insertion order (as +# in platformio-build.py) +_MMU_VARIANTS = { + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48": ( + "MMU_IRAM_SIZE=0xC000", + "MMU_ICACHE_SIZE=0x4000", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED": ( + "MMU_IRAM_SIZE=0xC000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_HEAP", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=128", + ), + # Upstream really does cap the 1024K option's heap knob at 256 + # (platformio-build.py's MMU_EXTERNAL_1024K branch); transliterated + # verbatim + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=256", + ), +} +# From platformio-build.py: the invariant framework defines every TU gets +# (ARDUINO=10805 encodes the IDE compatibility level); the board, flash-mode, +# knob, and MMU defines are composed around them in _defines_flags, in +# upstream's order. +_FRAMEWORK_DEFINES = ("__ets__", "ICACHE_FLASH", "_GNU_SOURCE", "ARDUINO=10805") +_ARCH_DEFINES = ("ESP8266", "ARDUINO_ARCH_ESP8266") + +# Upstream reads these from the board manifest (build.mmu_iram_size etc.); +# no supported board sets them, so the platformio-build.py defaults are +# hardcoded here rather than drift +_MMU_DEFAULT = ("MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000") + +# Upstream's CXXFLAGS (-fno-rtti, the -std level, -f(no-)exceptions) and the +# trailing stdc++/m/c/gcc system libs are composed at emission +# (write_project) from CORE.cpp_standard and _BuildConfig.exceptions. +_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"] +_CFLAGS = [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", +] +_CCFLAGS = [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", +] +# Upstream's -u _scanf_float is deliberately absent: it is re-added from +# KEY_SCANF_FLOAT at emission (the remove_float_scanf extra script's job). +_LINKFLAGS = [ + "-Os", + "-nostdlib", + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u", + "app_entry", + "-u", + "_printf_float", + "-u", + "_DebugExceptionVector", + "-u", + "_DoubleExceptionVector", + "-u", + "_KernelExceptionVector", + "-u", + "_NMIExceptionVector", + "-u", + "_UserExceptionVector", +] +_SYSTEM_LIBS_PRE_LWIP = ["hal", "phy", "pp", "net80211"] +_SYSTEM_LIBS_POST_LWIP = [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", +] + + +@dataclass +class _BuildConfig: + """Knob-derived build configuration (PIO_FRAMEWORK_ARDUINO_* defines).""" + + nonosdk: str + lwip_lib: str + exceptions: bool + vtables: str + fp_in_irom: bool + knob_defines: list[str] + mmu_defines: list[str] + + +def _lexed_build_flags() -> list[str]: + """Shell-lex ``CORE.build_flags`` as PlatformIO's ``ParseFlags`` does, + sorted so duplicate defines resolve deterministically. + + Lex once per build; consumers share the tokens. + """ + # The funnel warns and drops empty glued arguments (-D "") itself + return lex_build_flags(sorted(CORE.build_flags), "esphome") + + +def _flag_defines(unflags: set[str], tokens: list[str]) -> dict[str, str]: + """Map define name -> full ``NAME[=VALUE]`` for every -D build flag. + + ``tokens`` comes from one ``_lexed_build_flags()`` call shared with + ``_project_flags``, which already warned about and dropped any bare "-D". + """ + defines: dict[str, str] = {} + for tok in tokens: + # An unflagged knob must not drive lwIP/SDK/MMU selection while + # being absent from the compile line + if tok in unflags: + continue + if tok.startswith("-D"): + body = tok[2:] + defines[body.split("=", 1)[0]] = body + return defines + + +def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: + nonosdk = next( + ( + define + for name, define in _NONOSDK_VERSIONS.items() + if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines + ), + next(iter(_NONOSDK_VERSIONS.values())), + ) + # Same compile-line/linked-artifact split as the lwIP knobs below: a + # raw NONOSDK* would define a second SDK macro while the link still + # resolves against the knob's libraries + if raw_sdk := sorted(n for n in defines if n.startswith("NONOSDK")): + raise EsphomeError( + f"{', '.join(raw_sdk)} are set by the " + "PIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK* knobs; drop the raw " + "build flags" + ) + + lwip = next( + (variant for knob, variant in _LWIP_VARIANTS.items() if knob in defines), + _LWIP_DEFAULT, + ) + + # The lwIP triple selects a prebuilt library; a raw override would win + # the compile line (user tokens come last here) while the link still + # pulls the library built for the knob's values + if owned := sorted( + n for n in ("TCP_MSS", "LWIP_FEATURES", "LWIP_IPV6") if n in defines + ): + raise EsphomeError( + f"{', '.join(owned)} are set by the PIO_FRAMEWORK_ARDUINO_LWIP2_* " + "knobs; drop the raw build flags" + ) + knob_defines = [ + f"{nonosdk}=1", + f"TCP_MSS={lwip.tcp_mss}", + f"LWIP_FEATURES={lwip.features}", + f"LWIP_IPV6={lwip.ipv6}", + ] + if "PIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE" in defines: + knob_defines.append("WAVEFORM_LOCKED_PHASE=1") + + # Sorted so the pick is deterministic: the dict is built from a set of + # build flags, whose iteration order varies between processes. + vtables_knobs = sorted(name for name in defines if name.startswith("VTABLES_IN_")) + known_vtables = {"VTABLES_IN_FLASH", "VTABLES_IN_DRAM", "VTABLES_IN_IRAM"} + # A typo'd or conflicting knob would otherwise fail obscurely in the + # SDK header's #error + if unknown := [k for k in vtables_knobs if k not in known_vtables]: + raise EsphomeError(f"Unknown VTABLES_IN_* define(s): {', '.join(unknown)}") + # A body (e.g. VTABLES_IN_FLASH=0) would split the compile line from the + # linker script, which always defines the bare name + if valued := [defines[k] for k in vtables_knobs if defines[k] not in (k, f"{k}=1")]: + raise EsphomeError(f"VTABLES_IN_* defines take no value: {', '.join(valued)}") + if len(vtables_knobs) > 1: + raise EsphomeError( + f"Conflicting VTABLES_IN_* defines: {', '.join(vtables_knobs)}" + ) + vtables = vtables_knobs[0] if vtables_knobs else "VTABLES_IN_FLASH" + + mmu_knob = next((knob for knob in _MMU_VARIANTS if knob in defines), None) + if mmu_knob is not None: + if raw := sorted(n for n in defines if n.startswith("MMU_")): + # Same compile-line/linker-script split as the no-knob case below + fix = ( + f"drop {mmu_knob} to use the custom sizes" + if "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines + else "drop the raw MMU_* build flags or use " + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" + ) + raise EsphomeError(f"{', '.join(raw)} conflict with {mmu_knob}; {fix}") + mmu = list(_MMU_VARIANTS[mmu_knob]) + elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines: + if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines: + raise EsphomeError( + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and " + "MMU_ICACHE_SIZE build flags" + ) + for name in _MMU_SEGMENT_SIZE_NAMES: + # A bare -Dname would preprocess to len = 1 and fail far away + if "=" not in defines[name]: + raise EsphomeError( + f"{name} must be a hex literal (e.g. 0x8000), got (no value)" + ) + for name, body in defines.items(): + if not name.startswith("MMU_") or "=" not in body: + # Valueless flags (MMU_IRAM_HEAP) are legitimate switches + continue + # Every valued MMU_* reaches the linker-script preprocessor; a + # bare or non-numeric value would corrupt it and fail far away + # in ld. The two segment sizes must additionally be hex: + # build_surgery's segment parser cannot read decimal back. + value = body.partition("=")[2] + rule = ( + _MMU_HEX_VALUE_RE if name in _MMU_SEGMENT_SIZE_NAMES else _MMU_VALUE_RE + ) + if not rule.fullmatch(value): + shape = ( + "a hex literal (e.g. 0x8000)" + if name in _MMU_SEGMENT_SIZE_NAMES + else "a numeric literal" + ) + raise EsphomeError( + f"{name} must be {shape}, got {value or '(no value)'}" + ) + # Sorted so build.ninja and the linker-script stamp stay + # byte-stable across runs (the flag set has no deterministic + # iteration order). + mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_")) + else: + if raw := sorted(n for n in defines if n.startswith("MMU_")): + # Unlike PlatformIO (whose defaults win the compile line), user + # MMU_* here would win the compile but not the linker script; + # refuse them all, like the knob branch above. + raise EsphomeError( + f"Raw {', '.join(raw)} build flags require " + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" + ) + mmu = list(_MMU_DEFAULT) + + return _BuildConfig( + nonosdk=nonosdk, + lwip_lib=lwip.lib, + exceptions="PIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS" in defines, + vtables=vtables, + fp_in_irom="FP_IN_IROM" in defines, + knob_defines=knob_defines, + mmu_defines=mmu, + ) + + +_INCOMPLETE_INSTALL = "Arduino toolchain install is incomplete" + + +def _filter_link_flags(unflags: set[str]) -> list[str]: + """_LINKFLAGS minus ``unflags``, pair-aware: unflagging a symbol also + drops the ``-u`` that carried it, so no dangling operand-less flag + reaches ld as the next token's consumer.""" + out: list[str] = [] + it = iter(_LINKFLAGS) + for tok in it: + if tok == "-u": + symbol = next(it) + if symbol not in unflags: + out += [tok, symbol] + elif tok not in unflags: + out.append(tok) + return out + + +def _active_flash_ld_name(flash_ld_name: str) -> str: + """The flash linker-script filename the link uses (testing mode renames + the surgically patched copy).""" + return ( + f"{_TESTING_LD_PREFIX}{flash_ld_name}" if CORE.testing_mode else flash_ld_name + ) + + +def _flash_ld_name(board: str) -> str: + """The flash linker script: the board's, or a routed user override. + + Published configs override board_build.ldscript to reserve a + filesystem region or correct a board's assumed flash size; a bare + name is required because the script resolves via the -L search path. + """ + override = _pio_option("board_build.ldscript", "") + if not override: + # The same shared rule the PlatformIO path pins (layout + # preservation, see boards.board_ld_script) + return board_ld_script(BOARDS[board]) + if Path(override).name != override: + raise EsphomeError( + f"board_build.ldscript must be a bare script name, got {override!r}" + ) + return override + + +def _pio_option(key: str, default: str) -> str: + """A platformio_options value the native build honors (str-normalized). + + core/config.py routes these into ``CORE.platformio_options`` under the + arduino toolchain and already collapses a repeated option to its last + value (like a later platformio.ini line), so a scalar always arrives. + """ + value = CORE.platformio_options.get(key) + if value is None: + return default + value = str(value).strip() + if not value: + raise EsphomeError(f"platformio_options {key} is empty") + return value + + +def _defines_flags( + config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...] +) -> list[str]: + r"""The framework/board -D tokens for the compile line. + + The returned tokens already carry shell-level escaping (the board + defines embed ``\"``), so they must be emitted unquoted; wrapping + them in ``_shell_token`` would deliver literal backslashes to gcc. + ``flash_mode`` also lands unquoted: callers pass it pre-validated + against ``BUILD_FLASH_MODES`` (cv.one_of at config time, the + ``_FLASH_MODES`` check at the emission half's read site). + """ + if not _BOARD_NAME_RE.fullmatch(board): + # The name lands unquoted in two -D bodies; reject it by name + # instead of corrupting the compile line + raise EsphomeError(f"Invalid board name {board!r}") + # Every supported board ships 80 MHz; board_build.f_cpu overrides + f_cpu = _pio_option("board_build.f_cpu", _DEFAULT_F_CPU) + if not _F_CPU_RE.fullmatch(f_cpu): + # The value lands unquoted on the compile line; reject by name + # instead of corrupting it + raise EsphomeError(f"Invalid board_build.f_cpu value {f_cpu!r}") + return [ + f"-D{d}" + for d in ( + f"F_CPU={f_cpu}", + *_FRAMEWORK_DEFINES, + f'ARDUINO_BOARD=\\"PLATFORMIO_{board.upper()}\\"', + f'ARDUINO_BOARD_ID=\\"{board}\\"', + f"FLASHMODE_{flash_mode.upper()}", + "LWIP_OPEN_SRC", + *config.knob_defines, + config.vtables, + # User-supplied bodies re-quote like every other user token + # (a no-op for real MMU values) + *(_shell_token(d) for d in config.mmu_defines), + *_ARCH_DEFINES, + *board_defines, + ) + ] + + +def _unflag_tokens() -> set[str]: + """``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches.""" + # Lexed like _lexed_build_flags reads build_flags, so "-D FOO" removes + # -DFOO in both spellings (PlatformIO's ProcessUnFlags parses the same + # way) and no bare half can collaterally drop an unrelated token + return set(lex_build_flags(list(CORE.build_unflags), "esphome build_unflags")) + + +def _project_flags( + unflags: set[str], tokens: list[str] +) -> tuple[list[str], list[str], list[Path], list[str]]: + """Split the ESPHome build flags into compile, linker, -L, and -l lists. + + Plain-form linker flags (``_PLAIN_LINKER_FLAGS``/``_PLAIN_LINKER_PREFIXES``) + raise: they would be inert on the ``-c`` compile line. + ``compile_flags``/``link_flags`` come back shell-quoted; + ``lib_dirs``/``libs`` are raw, quote at emission. + """ + compile_flags: list[str] = [] + link_flags: list[str] = [] + lib_dirs: list[Path] = [] + libs: list[str] = [] + for tok in tokens: + if tok in unflags: + continue + # _lexed_build_flags warned about and dropped any bare -I/-D/-L/-l + if tok.startswith("-Wl,"): + link_flags.append(_shell_token(tok)) + elif tok.startswith("-L"): + lib_dirs.append(Path(tok[2:])) + elif tok.startswith("-l"): + libs.append(tok[2:]) + else: + if tok.startswith(_PLAIN_DRIVER_LINK_PREFIXES): + # Driver options with no -Wl, spelling; ld would reject them + raise EsphomeError( + f"Link flag {tok} in build_flags is not supported by the " + "native toolchain" + ) + if tok in _PLAIN_LINKER_FLAGS or tok.startswith(_PLAIN_LINKER_PREFIXES): + raise EsphomeError( + f"Linker flag {tok} in build_flags is not routed to the " + "link line; use the -Wl, form" + ) + if tok.startswith("-") and not tok.startswith(_COMPILE_FLAG_PREFIXES): + # The linker deny lists are not exhaustive; an unlisted + # link-only spelling would be inert on the -c compile line, + # so at least surface the odd shape + _LOGGER.warning( + "Build flag %s is not a recognized compile-flag shape; " + "it is passed to the compile line only", + tok, + ) + compile_flags.append(_shell_token(tok)) + return compile_flags, link_flags, lib_dirs, libs + + +# Recognized compile-flag shapes: the allow-list feeding the fall-through +# warning in _project_flags (an unlisted link-only spelling still reaches +# the compile line, but not silently) +_COMPILE_FLAG_PREFIXES = ( + "-D", + "-I", + "-U", + "-W", + "-f", + "-m", + "-O", + "-g", + "-std=", + "-include", +) +# Plain-form linker flags rejected by _project_flags: inert on a -c compile +# line, so the firmware would silently lack the requested link behavior. +# Best-effort, not exhaustive; see _COMPILE_FLAG_PREFIXES above. +_PLAIN_LINKER_FLAGS = ( + "-u", + "-e", + "-s", + "-static", + "-nostartfiles", + "-nodefaultlibs", + "-nostdlib", + "-rdynamic", +) +# The subset whose next token is an operand; unflagging the bare flag +# would strand the operand. Operand-less members of the list above filter +# whole-token from both the compile and link lines, as PlatformIO allows. +_PLAIN_LINKER_OPERAND_FLAGS = ("-u", "-e") +_PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") +# Driver options, not ld options: -Wl, has no equivalent for these +_PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") + + +def _stat_sig(path: Path) -> str: + """Size and mtime cache-stamp signature for one input file. + + Absent stays deterministic ("missing": the spawn names it); unreadable + forces a cache miss every run rather than pinning the stamp to a + constant that can never notice a later edit. + """ + try: + st = path.stat() + return f"{st.st_size}:{st.st_mtime_ns}" + except FileNotFoundError: + return "missing" + except OSError as err: + _LOGGER.warning( + "Could not stat %s (%s); regenerating the linker script every " + "build. Run 'esphome clean-all' to reinstall the framework.", + path, + err, + ) + return f"unreadable:{os.urandom(8).hex()}" + + +def _write_note(path: Path, text: str, *, warn: bool = False) -> bool: + """Best-effort bookkeeping write; a failure never fails the build. + + ``warn`` marks notes whose loss drops a diagnostic on later cached + builds; a lost stamp only costs a cache miss and stays at debug. + Returns whether the write persisted, so a lost warn note can veto + the cache stamp and keep the diagnostic re-derivable. + """ + try: + path.write_text(text, encoding="utf-8") + except OSError as err: + log = _LOGGER.warning if warn else _LOGGER.debug + log("Could not write %s: %s", path, err) + return False + return True + + +def generate_ld_scripts( + paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str +) -> None: + """Generate the common linker script (and testing-mode flash ld copy). + + Runs the same preprocessor invocation as the PlatformIO builder over + ``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi + rate-table DRAM relocation, and enlarged memory segments in testing mode. + """ + if not _FLASH_LD_NAME_RE.fullmatch(flash_ld_name): + # Joined under the SDK and build ld dirs; never a path or traversal + raise EsphomeError(f"Invalid flash linker script name {flash_ld_name!r}") + framework = paths.framework + gcc = toolchain_tool(paths.toolchain, "gcc") + ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld") + mkdir_p(ld_dir) + + cmd = [str(gcc), "-CC", "-E", "-P", f"-D{config.vtables}"] + cmd += [f"-D{d}" for d in config.mmu_defines] + if config.fp_in_irom: + cmd.append("-DFP_IN_IROM") + header = _sdk_ld_dir(framework) / _COMMON_LD_HEADER + cmd += [str(header), "-o", "-"] + + # The inputs are the command line (defines + framework version, which is + # baked into the paths) plus testing mode; skip the preprocessor spawn on + # incremental builds when nothing changed. + output = ld_dir / _COMMON_LD_NAME + stamp = ld_dir / f".{_COMMON_LD_NAME}.stamp" + # Stamp includes the header/gcc stat (catches in-place re-extraction) + # and the surgery fingerprint (a build_surgery edit invalidates old + # build dirs) + stamp_content = ( + # shlex.join: a spaced path stays one quoted element, so two + # different cmd lists can never collide to the same stamp string + shlex.join(cmd) + + f" testing={CORE.testing_mode}" + + f" header={_stat_sig(header)}" + + f" gcc={_stat_sig(gcc)}" + + f" {build_surgery.surgery_fingerprint()}" + ) + + stderr_note = ld_dir / f".{_COMMON_LD_NAME}.stderr" + + def _note_digest() -> str: + # The note is an output like the script itself; folding its state + # into the stamp makes an externally removed or edited note a cache + # miss that re-runs -E and re-derives the diagnostic + if not stderr_note.is_file(): + return "none" + return hashlib.sha256(stderr_note.read_bytes()).hexdigest() + + def _cached_ld_is_valid() -> bool: + # Any damaged cache regenerates; never abort the build over it. The + # stamp records the sha256 of the content written, so an externally + # edited script regenerates too. + try: + if not (output.is_file() and stamp.is_file()): + return False + rest, sep, digest = stamp.read_text(encoding="utf-8").rpartition( + " content=" + ) + inputs, note_sep, note_digest = rest.rpartition(" note=") + return ( + bool(sep) + and bool(note_sep) + and inputs == stamp_content + and note_digest == _note_digest() + and hashlib.sha256(output.read_bytes()).hexdigest() == digest + ) + except (OSError, UnicodeDecodeError): + return False + + if not _cached_ld_is_valid(): + try: + result = subprocess.run( + cmd, + capture_output=True, + check=False, + close_fds=False, + ) + except OSError as err: + # A half-extracted or half-deleted toolchain cache reaches here + raise EsphomeError(f"Could not run {gcc}: {err}; {_CLEAN_HINT}") from err + # Localized gcc diagnostics on a non-UTF-8 console must degrade, + # not UnicodeDecodeError the build; the script itself (below) is + # decoded strictly instead, so a mangled byte can never be cached + stderr_text = result.stderr.decode("utf-8", errors="replace") + if result.returncode != 0: + raise EsphomeError(f"Generating the linker script failed:\n{stderr_text}") + note_persisted = True + if stderr_text.strip(): + # Preprocessor warnings on the success path must reach the user + # on this and every later cached build (see the re-emit below) + _LOGGER.warning("Linker-script preprocessor: %s", stderr_text.strip()) + note_persisted = _write_note(stderr_note, stderr_text.strip(), warn=True) + else: + try: + stderr_note.unlink(missing_ok=True) + except OSError as err: + # A kept stale note would re-emit an obsolete diagnostic on + # every cache hit; skip the stamp so -E re-derives the truth + _LOGGER.warning( + "Could not remove %s (%s); the linker script will " + "regenerate every build until it is removable; %s", + stderr_note, + err, + _CLEAN_HINT, + ) + note_persisted = False + try: + stdout_text = result.stdout.decode("utf-8") + except UnicodeDecodeError as err: + # -CC keeps header comments verbatim; a non-UTF-8 byte replaced + # with U+FFFD would be cached as valid for the build dir's life + raise EsphomeError( + f"Preprocessed linker script from {header} is not UTF-8: " + f"{err}; {_CLEAN_HINT}" + ) from err + if "SECTIONS" not in stdout_text: + # A degenerate zero-exit run must not be stamped as a good cache + raise EsphomeError( + f"Generated linker script is missing its SECTIONS block; {_CLEAN_HINT}" + ) + content = _apply_surgery(build_surgery.relocate_ratetable, stdout_text) + if CORE.testing_mode: + content = _apply_surgery( + build_surgery.apply_testing_memory_patches, content, ("iram1_0_seg",) + ) + write_file_if_changed(output, content) + if note_persisted: + # An unstamped cache re-runs -E next build, re-deriving the + # diagnostic the lost note would have re-emitted + _write_note( + stamp, + f"{stamp_content} note={_note_digest()} " + f"content={hashlib.sha256(content.encode('utf-8')).hexdigest()}", + ) + elif stderr_note.is_file(): + # Re-emit cached preprocessor warnings on cache hits + try: + _LOGGER.warning( + "Linker-script preprocessor: %s", + stderr_note.read_text(encoding="utf-8"), + ) + except (OSError, UnicodeDecodeError) as err: + _LOGGER.warning( + "A cached linker-script preprocessor diagnostic exists at %s " + "but could not be read: %s", + stderr_note, + err, + ) + + if CORE.testing_mode: + _generate_testing_flash_ld(framework, ld_dir, flash_ld_name) + + +def _generate_testing_flash_ld( + framework: Path, ld_dir: Path, flash_ld_name: str +) -> None: + """A patched copy of the flash ld in the build dir; resolved through the + same -L path as the SDK original it shadows.""" + flash_ld = _sdk_ld_dir(framework) / flash_ld_name + try: + flash_ld_text = flash_ld.read_text(encoding="utf-8") + except OSError as err: + # Same half-extracted-cache hazard as the preprocessor spawn + raise EsphomeError(f"Could not read {flash_ld}: {err}; {_CLEAN_HINT}") from err + patched_flash_ld = _apply_surgery( + build_surgery.apply_testing_memory_patches, + flash_ld_text, + ("dram0_0_seg", "irom0_0_seg"), + ) + write_file_if_changed( + ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld + ) + + +class _FlagSets(NamedTuple): + cflags: list[str] + cxxflags: list[str] + asflags: list[str] + + +def _check_install(framework: Path, src_dir: Path, include_dirs: list[Path]) -> None: + """Fail by naming the missing path before any tool runs.""" + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + # A missing install directory would otherwise surface as a wall of + # include errors; failing here names the path instead + for required in include_dirs[1:]: + if not required.is_dir(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required}; {_CLEAN_HINT}" + ) + # The elf2bin edge runs after the full compile and link; a + # half-extracted package must fail here, not an hour of wall-clock later + for required_file in ( + framework / "tools" / "elf2bin.py", + framework / "bootloaders" / "eboot" / "eboot.elf", + ): + if not required_file.is_file(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required_file}; {_CLEAN_HINT}" + ) + + +def _resolve_flag_sets( + config: _BuildConfig, + defines: list[str], + includes: list[str], + project_compile_flags: list[str], + build_tokens: list[str], + unflags: set[str], +) -> _FlagSets: + """The c/cxx/as flag sets with ``build_unflags`` applied.""" + common = _CCFLAGS + defines + includes + project_compile_flags + cflags = _CFLAGS + common + cpp_standard = CORE.cpp_standard or "gnu++17" + cxxflags = ( + ["-fno-rtti", f"-std={cpp_standard}"] + + ["-fexceptions" if config.exceptions else "-fno-exceptions"] + + common + + [_shell_token(f) for f in get_project_cxx_compile_flags()] + ) + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly; match + # it (tokens arrive shell-quoted, hence the lstrip) + asflags = ( + _ASFLAGS + + defines + + includes + + [f for f in project_compile_flags if f.lstrip("\"'").startswith(("-D", "-I"))] + ) + # build_unflags applies to the framework flag sets too, as under + # PlatformIO; matching is whole-token, so an unflag that hits nothing + # anywhere must be visible + flag_universe = set(build_tokens) + for flags in (cflags, cxxflags, asflags, _LINKFLAGS): + flag_universe.update(flags) + if unmatched := sorted(unflags - flag_universe): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", ", ".join(unmatched) + ) + # _LINKFLAGS stores -u and its operand as two tokens; unflagging the + # bare -u would strip all seven and leave the operands as ld "input + # files" with an error pointing nowhere near build_unflags + if plain := sorted( + u + for u in unflags + if u in _PLAIN_LINKER_OPERAND_FLAGS or u.startswith(_PLAIN_LINKER_PREFIXES) + ): + raise EsphomeError( + f"build_unflags cannot remove plain linker flag(s) " + f"{', '.join(plain)}; unflag the full -Wl, form or the symbol" + ) + return _FlagSets( + *( + [f for f in flags if f not in unflags] + for flags in (cflags, cxxflags, asflags) + ) + ) + + +def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled) + so one build never pays the runnability probe per consumer. Returns + True when ``build.ninja`` changed, so the caller can skip work derived + purely from it (the compile database) on unchanged builds. + """ + from esphome.arduino.library import resolve_libraries + + framework = paths.framework + toolchain_bin = paths.toolchain / "bin" + build_dir = CORE.relative_pioenvs_path(CORE.name) + mkdir_p(build_dir) + + unflags = _unflag_tokens() + # Lexed once so a malformed entry warns once, not per consumer + build_tokens = _lexed_build_flags() + flag_defines = _flag_defines(unflags, build_tokens) + config = _resolve_build_config(flag_defines) + esp8266_data = CORE.data[KEY_ESP8266] + board = esp8266_data[KEY_BOARD] + # Config validation already gates boards; + # kept as defense-in-depth for direct calls, since CONF_BOARD itself is + # a free-form string + if board not in ESP8266_BOARD_BUILD: + raise EsphomeError(f"Board '{board}' is not supported by the native toolchain") + board_build = ESP8266_BOARD_BUILD[board] + # From the same producer the PlatformIO path reads (one source) + flash_mode = _pio_option("board_build.flash_mode", "dout") + if flash_mode not in _FLASH_MODES: + # Lands unquoted in the elf2bin command and a -D body; validation + # (cv.one_of on board_flash_mode) already gates it, defense-in-depth + raise EsphomeError(f"Invalid flash mode {flash_mode!r}") + flash_ld_name = _flash_ld_name(board) + + sdk = framework / "tools" / "sdk" + core_dir = framework / "cores" / "esp8266" + variant_dir = framework / "variants" / board_build["variant"] + src_dir = CORE.relative_src_path() + + include_dirs = [ + src_dir, + sdk / "include", + core_dir, + paths.toolchain / "include", + sdk / "lwip2" / "include", + variant_dir, + ] + # Completeness checks run before generate_ld_scripts spawns gcc so a + # half-extracted install names the missing path, not a gcc error + _check_install(framework, src_dir, include_dirs) + + generate_ld_scripts(paths, config, flash_ld_name) + + libraries = resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + for lib in libraries: + include_dirs += lib.include_dirs + + ( + project_compile_flags, + project_link_flags, + project_lib_dirs, + project_libs, + ) = _project_flags(unflags, build_tokens) + flag_sets = _resolve_flag_sets( + config, + _defines_flags(config, flash_mode, board, board_build["defines"]), + [f"-I{_q(d)}" for d in include_dirs], + project_compile_flags, + build_tokens, + unflags, + ) + link_flags = _filter_link_flags(unflags) + if esp8266_data[KEY_SCANF_FLOAT]: + link_flags += ["-u", "_scanf_float"] + link_flags += project_link_flags + link_flags += [_shell_token(flag) for lib in libraries for flag in lib.link_flags] + flash_ld = _active_flash_ld_name(flash_ld_name) + # A user-overridden script name re-quotes like every other user token + link_flags += ["-T", _shell_token(flash_ld)] + + lib_dirs = [Path("ld"), sdk / "lib", sdk / "ld", sdk / "lib" / config.nonosdk] + lib_dirs += project_lib_dirs + for lib in libraries: + lib_dirs += lib.link_dirs + system_libs = ( + _SYSTEM_LIBS_PRE_LWIP + + [config.lwip_lib] + + _SYSTEM_LIBS_POST_LWIP + + project_libs + + [lib_name for lib in libraries for lib_name in lib.link_libs] + + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] + ) + + cxx = (toolchain_tool(paths.toolchain, "g++"),) + lines = [ + *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), + *compile_rule_lines(), + *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), + *pch_rule_lines(), + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + "rule elf2bin", + # --flash_size deliberately stays board-derived, as under + # PlatformIO (which reads upload.maximum_size, not the ldscript). + # -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+. + f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + " description = BIN $out", + "rule copy", + " command = $python $buildtool copy $in $out", + " description = COPY $out", + "", + f"cflags = {' '.join(flag_sets.cflags)}", + f"cxxflags = {' '.join(flag_sets.cxxflags)}", + f"asflags = {' '.join(flag_sets.asflags)}", + f"linkflags = {' '.join(link_flags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(_shell_token(f'-l{lib}') for lib in system_libs)}", + "", + ] + + core_exclude = set(_CORE_EXCLUDE_ALWAYS) + if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: + core_exclude |= _CORE_EXCLUDE_WAVEFORM + + archives = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = collect_sources(variant_dir) + if variant_sources: + objs = compile_edges(lines, variant_sources, variant_dir, "variant") + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = compile_edges( + lines, collect_sources(core_dir, core_exclude), core_dir, "core" + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + lib_archives, direct_objs = library_edges(lines, libraries) + archives += lib_archives + + # One source of truth with the PlatformIO path: esp8266/__init__ pins + # build_src_flags (the throw_stubs force-include); -include paths + # resolve against the source root + src_other: list[str] = [] + src_includes: list[str] = [] + src_it = iter( + lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags") + ) + for tok in src_it: + if tok == "-include": + header = next(src_it, "") + if not header: + raise EsphomeError( + "build_src_flags has a trailing '-include' with no header" + ) + src_includes.append(header) + elif is_joined_include(tok): + # Left in src_other it would precede the pch include + src_includes.append(tok[len("-include") :]) + else: + src_other.append(_shell_token(tok)) + include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] + # One shared variable instead of repeating the flags line on every src + # edge (hundreds of edges in a real project) + lines.append(f"srcflags = {' '.join(src_other + include_flags)}") + src_cxx_override = pch_edges( + lines, + build_dir, + src_dir, + (*src_includes, *PCH_DEFAULT_HEADERS), + flag_sets.cxxflags, + src_other, + (str(paths.framework), str(paths.toolchain)), + cxx, + ) + src_objs = compile_edges( + lines, + collect_sources(src_dir), + src_dir, + "src", + flags="$srcflags", + cxx_override=src_cxx_override, + ) + + ld_deps = [f"ld/{_COMMON_LD_NAME}"] + if CORE.testing_mode: + ld_deps.append(f"ld/{flash_ld}") + lines.append( + f"build firmware.elf: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)} {' '.join(_e(d) for d in ld_deps)}" + ) + lines.append(f" archives = {' '.join(_shell_token(a) for a in archives)}") + lines.append("build firmware.bin: elf2bin firmware.elf") + lines.append("build firmware.factory.bin: copy firmware.bin") + lines.append("build firmware.ota.bin: copy firmware.bin") + lines.append("default firmware.factory.bin firmware.ota.bin") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) + + +def get_flash_ld_path(build_dir: Path, paths: InstalledPaths) -> Path: + """The flash linker script the link actually uses (for size reporting). + + Reads the same install the ninja file linked against instead of + re-resolving the framework version. A user-shipped override living in a + custom -L dir resolves to a nonexistent path here; the size consumer + warns and skips the Flash summary then. + """ + name = _active_flash_ld_name(_flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD])) + if CORE.testing_mode: + return build_dir / "ld" / name + return paths.framework / "tools" / "sdk" / "ld" / name + + +def _flash_size_str(flash_size: int) -> str: + """Flash size argument for elf2bin (e.g. ``4M``, ``512K``).""" + mb = 1024 * 1024 + return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K" diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 2ef89cf595b..9f48013a4e8 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,9 @@ import json import logging from pathlib import Path +import textwrap +from esphome.build_helpers import pch from esphome.components.esp32 import ( get_esp32_variant, get_excluded_builtin_components, @@ -90,9 +92,10 @@ def get_project_cmakelists( """ idf_target = variant_to_idf_target(get_esp32_variant()) - # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and - # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get - # --format=raw because the legacy mode doesn't support it. + # esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng; + # 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks + # total_size, hence the ELF fallback in espidf/size_summary.py; both + # go away together when 1.x support is dropped. size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" # Project-wide compile options: -D defines and -W warning flags (skip @@ -211,10 +214,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) -# Emit raw JSON size data for ESPHome to read post-build. +# Emit per-memory-type JSON size data for ESPHome to read post-build. +# json2 stays small; raw dumps every symbol (~2s on a large map) and +# this command runs inside the link edge, blocking everything downstream. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} @@ -279,9 +284,72 @@ idf_component_register( target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} ) +{_pch_cmake_block()}""" + + +# Where CMake puts the .gch of the src component; ccache reads the checksum +# next to it in place of the .gch +_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum" +# Where the Windows gate records its choice +_PCH_CHOICE_VAR = "ESPHOME_PCH" + + +def _pch_cmake_block() -> str: + """The CMake block that precompiles the core headers for the C++ sources + of the src component; empty when disabled.""" + if not pch.pch_enabled(): + return "" + headers = "\n".join( + f' "$<$:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"' + for header in pch.PCH_DEFAULT_HEADERS + ) + block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE +{headers} +)""" + if not pch.pch_needs_gcc_check(): + return f"\n# ESPHome precompiled header\n{block}\n" + # Before the first configure only CMake knows the compiler version + return f""" +# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC})) + message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it") + set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE) +else() + set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE) +{textwrap.indent(block, " ")} +endif() """ +def _read_if_exists(path: Path) -> str: + return path.read_text(encoding="utf-8") if path.is_file() else "" + + +def write_pch_checksum() -> None: + """Write the checksum ccache uses in place of the .gch: the core headers, + the framework version, the sdkconfig and the managed component versions.""" + from esphome.espidf.toolchain import get_cmake_cache_value + + if not pch.pch_enabled(): + return + # The gate's choice, cached by configure + if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON": + return + pch.log_pch_in_use() + checksum = pch.pch_checksum( + CORE.relative_src_path(), + pch.PCH_DEFAULT_HEADERS, + ( + str(idf_version()), + _read_if_exists(CORE.relative_build_path(f"sdkconfig.{CORE.name}")), + _read_if_exists(CORE.relative_build_path("dependencies.lock")), + ), + ) + path = CORE.relative_build_path(_PCH_SUM_PATH) + path.parent.mkdir(parents=True, exist_ok=True) + write_file_if_changed(path, checksum + "\n") + + def write_project( minimal: bool = False, builtin_components: list[str] | None = None ) -> None: diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py new file mode 100644 index 00000000000..f1e53e1ccb2 --- /dev/null +++ b/esphome/build_gen/host.py @@ -0,0 +1,303 @@ +"""Native ninja build generator for the host platform. + +Emits ``build.ninja`` under ``.pioenvs//``: every source in the +generated ``src/`` tree plus the resolved registry libraries compiles with +the machine's compiler and links into ``program``, the name PlatformIO's +native platform produced. Build flags route the way SCons's ``ParseFlags`` +did under PlatformIO: ``-D``/``-I``/``-std=``/``-W`` shapes reach the +compile lines only, ``-l``/``-L``/``-Wl,`` the link line only, everything +else both. +""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +import os +from pathlib import Path +import subprocess +import sys +from typing import TYPE_CHECKING + +from esphome.build_helpers.ninja import escape as _e, quote_path as _q, shell_token +from esphome.build_helpers.ninja_gen import ( + PATH_ARG_FLAGS, + Flag, + anchor_path_flag, + ar_rule_lines, + collect_sources, + compile_edges, + compile_rule_lines, + library_edges, + pch_edges, + pch_rule_lines, + tool_lines, +) +from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_enabled +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_project_cxx_compile_flags +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.host.toolchain import PROGRAM_NAME, HostCompilers, find_tool, get_build_dir +from esphome.platformio.library import lex_build_flags + +if TYPE_CHECKING: + from esphome.arduino.library import ArduinoLibrary + +_LOGGER = logging.getLogger(__name__) + +# The PlatformIO platform the host built under; registry manifests declare +# compatibility against it, as lib_compat_mode=strict checked before +PIO_PLATFORM = "native" +# Namespaces the shared library download cache (pio_components/host/) +LIBRARY_CACHE_KEY = "host" + +# Flag shapes that only the compiler understands; dropped from the link line +_COMPILE_ONLY_PREFIXES = ("-D", "-U", "-I", "-std=", "-W", *PATH_ARG_FLAGS) +# Flag shapes that only the linker consumes; inert on a -c compile line +_LINK_ONLY_PREFIXES = ("-l", "-L", "-Wl,") +# Link-only flags whose argument is the next token +_LINK_ONLY_ARG_FLAGS = ("-framework", "-Xlinker", "-z") + + +def parse_flags(entries: Iterable[str], owner: str) -> list[Flag]: + """Lex build flag entries into flags, each with its argument. + + Entries are a set, so their order is not the user's: a flag and the + argument it takes as the next token must share one entry. + """ + flags: list[Flag] = [] + for entry in entries: + it = iter(lex_build_flags(entry, owner)) + for tok in it: + if tok not in PATH_ARG_FLAGS and tok not in _LINK_ONLY_ARG_FLAGS: + flags.append((tok,)) + continue + arg = next(it, None) + # A path never starts with "-"; that is the next flag + if arg is None or (tok in PATH_ARG_FLAGS and arg.startswith("-")): + raise EsphomeError( + f"{owner} build flags have '{tok}' with no argument; write " + f"the flag and its argument as one entry" + ) + flags.append((tok, arg)) + return flags + + +def split_flags(flags: list[Flag]) -> tuple[list[Flag], list[Flag]]: + """Route build flags to the compile and link lines.""" + compile_flags: list[Flag] = [] + link_flags: list[Flag] = [] + for flag in flags: + name = flag[0] + if len(flag) > 1: + (compile_flags if name in PATH_ARG_FLAGS else link_flags).append(flag) + elif name.startswith(_LINK_ONLY_PREFIXES): + # Checked before the compile prefixes: -Wl, would match -W + link_flags.append(flag) + elif name.startswith(_COMPILE_ONLY_PREFIXES): + compile_flags.append(flag) + else: + # -g, -O, -f*, -m*, -pthread, --coverage: both lines, as SCons + compile_flags.append(flag) + link_flags.append(flag) + return compile_flags, link_flags + + +def _is_std(flag: Flag) -> bool: + return flag[0].startswith("-std=") + + +def _is_cxx_std(flag: Flag) -> bool: + return _is_std(flag) and "++" in flag[0] + + +def _anchored_flags(entries: Iterable[str], owner: str) -> list[Flag]: + build_path = Path(CORE.build_path) + return [ + anchor_path_flag(flag, build_path) + for flag in parse_flags(sorted(entries), owner) + ] + + +def _flag_lists() -> tuple[list[str], list[str], list[str]]: + """The C, C++, and link flag lists (raw tokens), build_unflags applied. + + ``cg.set_cpp_standard`` wins over any ``-std=`` in the build flags for + C++ compiles, as PlatformIO's unflag of every other standard did; C + compiles never see a C++ standard. + """ + # The funnel warns and drops empty glued arguments (-D "") itself + compile_flags, link_flags = split_flags( + _anchored_flags(CORE.build_flags, "esphome") + ) + cflags = [f for f in compile_flags if not _is_cxx_std(f)] + cxx_std = CORE.cpp_standard + cxxflags = [f for f in compile_flags if not (cxx_std and _is_std(f))] + if cxx_std: + cxxflags.insert(0, (f"-std={cxx_std}",)) + cxxflags += [(tok,) for tok in get_project_cxx_compile_flags()] + + # A flag is removed whole, with its argument, as PlatformIO did + unflags = set(_anchored_flags(CORE.build_unflags, "esphome build_unflags")) + # An unflag that hits nothing (a typo, or -DUSE_FOO against + # -DUSE_FOO=1) must be visible, since the user believes the flag is + # gone while it still drives the build + if unmatched := sorted(unflags - set(cflags) - set(cxxflags) - set(link_flags)): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", + ", ".join(" ".join(flag) for flag in unmatched), + ) + + def keep(flags: list[Flag]) -> list[str]: + return [tok for flag in flags if flag not in unflags for tok in flag] + + return keep(cflags), keep(cxxflags), keep(link_flags) + + +def _resolve_host_libraries() -> list[ArduinoLibrary]: + """Every ``cg.add_library()`` entry, fetched from the registry. + + The host has no framework, so nothing is bundled and no framework + compatibility check applies; the platform check keeps the strict + manifest gate PlatformIO's native platform enforced. Manifest-less + libraries (a bare git checkout) build with PlatformIO's default + layout, as they did under its native platform. + """ + if not CORE.platformio_libraries: + return [] + from esphome.arduino.library import resolve_libraries + + return resolve_libraries( + None, + pio_platform=PIO_PLATFORM, + board_mcu="host", + cache_key=LIBRARY_CACHE_KEY, + framework=None, + manifest_optional=True, + ) + + +def _file_macro_maps(build_dir: Path) -> list[str]: + """Flags that keep ``__FILE__`` relative to the build path. + + PlatformIO compiled ``src/x.cpp`` from the build path, and tools name + things after that spelling (CodSpeed's benchmark ids). Here a source + reaches the compiler by its absolute path, or relative to the build + directory when ccache rewrites it. + """ + build_path = Path(CORE.build_path) + prefixes = (build_path, Path(os.path.relpath(build_path, build_dir))) + return [ + shell_token(f"-fmacro-prefix-map={prefix}{os.sep}=", force=True) + for prefix in prefixes + ] + + +def _compiler_version(cxx: tuple[str, ...]) -> str: + """What the compiler says it is: its path can stay the same across an + update (the macOS shims in /usr/bin).""" + result = subprocess.run( + [*cxx, "--version"], capture_output=True, text=True, check=False + ) + return result.stdout + + +def write_project(compilers: HostCompilers, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled). + Returns True when ``build.ninja`` changed, so the caller can skip work + derived purely from it (the compile database) on unchanged builds. + """ + build_dir = get_build_dir() + mkdir_p(build_dir) + src_dir = CORE.relative_src_path() + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + + cflags, cxxflags, link_flags = _flag_lists() + libraries = _resolve_host_libraries() + + include_dirs = [src_dir] + for lib in libraries: + include_dirs += lib.include_dirs + includes = [f"-I{_q(d)}" for d in include_dirs] + includes += _file_macro_maps(build_dir) + + # SCons's link line: $LINKFLAGS $SOURCES $_LIBDIRFLAGS $_LIBFLAGS, so + # -L and -l trail the objects while every other link token leads + lib_dirs = [Path(t[2:]) for t in link_flags if t.startswith("-L")] + libs = [t for t in link_flags if t.startswith("-l")] + linkflags = [shell_token(t) for t in link_flags if not t.startswith(("-L", "-l"))] + for lib in libraries: + lib_dirs += lib.link_dirs + libs += [f"-l{name}" for name in lib.link_libs] + linkflags += [shell_token(f) for f in lib.link_flags] + + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly + asflags = [t for t in cflags if t.startswith(("-D", "-I"))] + + lines = [ + *tool_lines(compilers.cc, compilers.cxx, ccache), + *compile_rule_lines(), + *pch_rule_lines(), + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + ] + if any(lib.sources and lib.lib_archive for lib in libraries): + # Resolved only when an archive is built, so a system without + # binutils still links a library-free configuration + lines += ar_rule_lines(find_tool("AR", ("ar",))) + lines += [ + "", + f"cflags = {' '.join([*map(shell_token, cflags), *includes])}", + f"cxxflags = {' '.join([*map(shell_token, cxxflags), *includes])}", + f"asflags = {' '.join([*map(shell_token, asflags), *includes])}", + f"linkflags = {' '.join(linkflags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(shell_token(lib) for lib in libs)}", + "", + ] + + archives, direct_objs = library_edges(lines, libraries) + + src_cxx_override = pch_edges( + lines, + build_dir, + src_dir, + PCH_DEFAULT_HEADERS, + # The arguments of a CXX override come before the flags + [*compilers.cxx[1:], *cxxflags], + (), + (compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (), + compilers.cxx, + ) + src_objs = compile_edges( + lines, + collect_sources(src_dir), + src_dir, + "src", + cxx_override=src_cxx_override, + ) + if not src_objs: + raise EsphomeError(f"No source files found under {src_dir}") + + # Archives are not topologically sorted; GNU ld needs the group to + # resolve references between them. ld64 loads archives iteratively and + # rejects the option, so macOS lists them bare. + archive_tokens = [shell_token(a) for a in archives] + if archive_tokens and sys.platform != "darwin": + archive_tokens = ["-Wl,--start-group", *archive_tokens, "-Wl,--end-group"] + lines.append( + f"build {PROGRAM_NAME}: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)}" + ) + lines.append(f" archives = {' '.join(archive_tokens)}") + lines.append(f"default {PROGRAM_NAME}") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 0a12d344a02..b68ee90f123 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,6 +1,8 @@ +from esphome.build_helpers.pch import pch_script_enabled from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed +from esphome.platformio.toolchain import copy_pch_script from esphome.writer import find_begin_end INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" @@ -62,6 +64,8 @@ def get_ini_content(): # Add extra script for C++ flags CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) + if pch_script_enabled(): + CORE.add_platformio_option("extra_scripts", ["post:pch.py"]) # Add CMake args. A user-supplied value (str or list) is deliberately # replaced; this option was always overwritten at FINAL priority. @@ -106,6 +110,8 @@ def write_project(): # Write extra script for C++ specific flags write_cxx_flags_script() + if pch_script_enabled(): + copy_pch_script() CXX_FLAGS_FILE_NAME = "cxx_flags.py" diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 5b5c7f247fb..df9aa8c9a09 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -68,6 +68,15 @@ def resolve_ccache_path() -> str | None: return ccache +def resolve_absolute_ccache_path() -> str | None: + """``resolve_ccache_path`` for the ninja backends, which run their + commands from the build directory, where a relative path is lost.""" + from esphome.build_helpers.ninja import absolute_tool + + ccache = resolve_ccache_path() + return absolute_tool(ccache) if ccache else None + + def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: """Default ``CCACHE_*`` values for a build subprocess (not os.environ). @@ -84,9 +93,39 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: "CORE.build_path must be set before constructing the build environment" ) defaults = { - "CCACHE_DIR": str(cache_dir), + # ccache expands $VAR in its settings; $$ is a literal $ + "CCACHE_DIR": str(cache_dir).replace("$", "$$"), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", - "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()).replace("$", "$$"), } return {k: v for k, v in defaults.items() if k not in os.environ} + + +def effective_ccache_basedir() -> str: + """The prefix ccache strips from hashed paths: a usable user + CCACHE_BASEDIR, else the resolved build path.""" + 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 + return str(Path(CORE.build_path).resolve()) + + +def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]: + """The ccache settings for a build subprocess (not os.environ). + + ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when + disabled; ``tools_cache`` is the backend's tools cache spec, which holds + its ccache dir. The pch settings include ``time_macros``: a cached + object can keep an older ``__DATE__`` or ``__TIME__``. + """ + if ccache is None: + return {} + from esphome.build_helpers.pch import ccache_pch_env + from esphome.build_helpers.tools_cache import tools_cache_path + + env = ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache") + env.update(ccache_pch_env()) + return env diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 038fe649703..62bea830225 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -11,6 +11,7 @@ consumers (IDE integration, clang-tidy) expect: from __future__ import annotations +from collections.abc import Callable import json import logging import os @@ -21,6 +22,8 @@ import subprocess from esphome.core import EsphomeError from esphome.helpers import write_file +_LOGGER = logging.getLogger(__name__) + # Everything idedata generation may raise after a successful link; idedata # is a bonus artifact, so consumers warn instead of failing the build IDEDATA_BEST_EFFORT_ERRORS = ( @@ -31,7 +34,21 @@ IDEDATA_BEST_EFFORT_ERRORS = ( ValueError, ) -_LOGGER = logging.getLogger(__name__) + +def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None: + """Run an idedata generator, downgrading any failure to a warning: + the firmware already built.""" + 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, + ) + _LOGGER.debug("Idedata failure detail", exc_info=True) + # C++ translation-unit suffixes used to identify ESPHome source files. _CXX_SUFFIXES = (".cpp", ".cc") @@ -135,6 +152,15 @@ def _is_launcher(token: str) -> bool: 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( entry: dict, launcher: str | None = None ) -> tuple[str, list[str], list[str], list[str]]: diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py new file mode 100644 index 00000000000..1d2dc1b3cae --- /dev/null +++ b/esphome/build_helpers/native.py @@ -0,0 +1,74 @@ +"""Resolution of the native (non-PlatformIO) build backend for a config. + +Kept deliberately light: the serial upload and logs fast path resolves a +backend for its artifact paths alone, so importing this must not pull in a +platform component package or the backend itself. +""" + +from __future__ import annotations + +from collections.abc import Collection +import importlib +import logging +from types import ModuleType + +from esphome.const import Toolchain +from esphome.core import CORE, EsphomeError + +_LOGGER = logging.getLogger(__name__) + +# Native build backend per (target platform, toolchain) +NATIVE_TOOLCHAIN_MODULES = { + ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", + ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", + ("host", Toolchain.HOST): "esphome.host.toolchain", +} + + +def native_backend() -> ModuleType | None: + """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) + + +# Binutils and the linked image for memory analysis, for toolchains that build +# without PlatformIO but have no native build backend (which supplies them) +ANALYSIS_TOOLCHAIN_MODULES = { + ("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain", +} + + +def analysis_backend() -> ModuleType | None: + """The module giving objdump, readelf and the ELF of a non-PlatformIO build. + + None means PlatformIO's idedata supplies them (or nothing can). + """ + if (native := native_backend()) is not None: + return native + module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain)) + return importlib.import_module(module_path) if module_path else None + + +def warn_ignored_platformio_options(consumed: Collection[str]) -> None: + """Warn for component-added platformio options a native build drops. + + User-supplied keys were already routed or warned about by + ``core/config.py``; what survives into ``CORE.platformio_options`` came + from ``cg.add_platformio_option`` calls in components. + """ + 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 '%s' toolchain", + key, + CORE.toolchain.value, + ) diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py index 8c25bc95130..7634b0f2f34 100644 --- a/esphome/build_helpers/ninja.py +++ b/esphome/build_helpers/ninja.py @@ -2,14 +2,17 @@ from __future__ import annotations +import json import logging import os from pathlib import Path import re import shutil +import subprocess from esphome.core import EsphomeError from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs +from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -23,11 +26,26 @@ def _ninja_runs(binary: str) -> bool: ) +# Compile rule names the generators emit; ninja's compdb tool is asked for +# exactly these, so a renamed rule fails the build instead of stranding idedata +COMPILE_RULES = ("c", "cxx", "aspp", "asm") + + +def absolute_tool(tool: str | Path) -> str: + """A tool path that still resolves from the build directory. + + ``shutil.which`` returns a relative path for a relative PATH entry, and + ninja runs the commands from ``.pioenvs/``. Symlinks are kept: + ccache's compiler links depend on the name they are called by. + """ + return strip_win_long_path_prefix(str(Path(tool).absolute())) + + def find_ninja() -> Path: """Locate the ninja binary: a runnable PATH hit first, else the ninja PyPI wheel.""" if binary := shutil.which("ninja"): - binary = strip_win_long_path_prefix(binary) + binary = absolute_tool(binary) if _ninja_runs(binary): return Path(binary) import_error: ImportError | None = None @@ -49,8 +67,18 @@ def find_ninja() -> Path: def escape(value: Path | str) -> str: - """Escape a path or token for a ninja file.""" - return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + """Escape a path or token for a ninja file. + + ninja has no escape for ``|`` or a line break in a path, so those fail + here by name instead of producing a build file ninja misreads. + """ + text = str(value) + if bad := next((c for c in "|\r\n" if c in text), None): + raise EsphomeError( + f"Path {text!r} contains {bad!r}, which a ninja build file cannot " + "express; rename the file or directory" + ) + return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ") def quote_arg(tok: str) -> str: @@ -90,3 +118,65 @@ def shell_token(tok: str, force: bool = False) -> str: def quote_path(value: Path | str) -> str: """Force-quote a path for the ninja command line (shell/CreateProcess).""" return shell_token(str(value), force=True) + + +def refresh_compile_commands( + ninja_path: Path, build_dir: Path, env: dict[str, str], ninja_changed: bool +) -> None: + """Regenerate the compile DB (a pure function of build.ninja) when stale. + + Freshness rides a stamp: the DB itself is written through + write_file_if_changed (its mtime feeds the idedata cache), so a + regeneration with identical content would stay "stale" forever. An + interrupted previous run may have rewritten the manifest without + regenerating the DB, hence the mtime comparison. + """ + compdb = build_dir / "compile_commands.json" + compdb_stamp = build_dir / ".compile_commands.stamp" + ninja_file = build_dir / "build.ninja" + 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(ninja_path, build_dir, env) + compdb_stamp.touch() + + +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", *COMPILE_RULES], + 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 stays valid + write_file_if_changed(compdb, result.stdout) diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py new file mode 100644 index 00000000000..8e72b36b3fd --- /dev/null +++ b/esphome/build_helpers/ninja_gen.py @@ -0,0 +1,279 @@ +"""The parts of a ``build.ninja`` every native backend emits the same way. + +Rule names match ``SOURCE_KIND_FOR_SUFFIX`` values (c, cxx, asm, aspp) and +``ninja.COMPILE_RULES``, which the compile database is asked for. +""" + +from __future__ import annotations + +from collections.abc import Collection, Sequence +import logging +import os +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from esphome.build_helpers.idedata import is_joined_include +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) +from esphome.build_helpers.pch import ( + PCH_GCH_NAME, + PCH_SUM_NAME, + log_pch_in_use, + pch_consumer_flags, + pch_identity, + pch_usable, + write_pch_headers, +) +from esphome.framework_helpers import strip_win_long_path_prefix +from esphome.helpers import write_file_if_changed +from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX + +if TYPE_CHECKING: + from esphome.arduino.library import ArduinoLibrary + +_LOGGER = logging.getLogger(__name__) + +_BUILD_TOOL = Path(__file__).parent.parent / "build_gen" / "build_tool.py" + + +def collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: + return sorted( + p + for p in root.rglob("*") + if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude + ) + + +def common_parent(paths: list[Path]) -> Path: + return Path(os.path.commonpath([str(p.parent) for p in paths])) + + +def _command(words: Sequence[Path | str]) -> str: + program, *args = words + return " ".join([_q(program), *(_shell_token(str(a)) for a in args)]) + + +def tool_lines( + cc: Sequence[Path | str], cxx: Sequence[Path | str], ccache: str | None +) -> list[str]: + """The file header: the compilers and the helper tools as variables. + + A compiler is its program followed by any arguments it always takes. + """ + return [ + "# Auto-generated by ESPHome", + "ninja_required_version = 1.5", + f"cc = {_command(cc)}", + f"cxx = {_command(cxx)}", + # The NSIS launcher starts Python with a \\?\ extended-length path + # that cmd.exe cannot spawn; same strip every other emitted binary + # path gets + f"python = {_q(strip_win_long_path_prefix(sys.executable))}", + f"buildtool = {_q(_BUILD_TOOL)}", + f"ccache = {_q(ccache) if ccache else ''}", + "", + ] + + +def compile_rule_lines() -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" + return [ + "rule c", + " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = AS $out", + # Plain assembler, as SCons's ASCOM: no preprocessor, so no + # depfile and no $flags (defines/includes) either + "rule asm", + " command = $ccache $cc -x assembler $asflags -c $in -o $out", + " description = AS $out", + ] + + +def pch_rule_lines() -> list[str]: + """The precompiled header rule, for a generator that emits one.""" + return [ + # No $ccache: the .gch embeds build dir paths + "rule pch", + " command = $cxx -MMD -MF $out.d -x c++-header $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = PCH $out", + ] + + +def pch_edges( + lines: list[str], + build_dir: Path, + src_dir: Path, + headers: Sequence[str], + cxxflags: Sequence[str], + src_flags: Sequence[str], + identity: Sequence[str], + cxx: Sequence[Path | str], +) -> tuple[str, str] | None: + """Emit the precompiled header for the C++ src edges. + + ``headers`` are folded into one prefix header, ``src_flags`` are the + flags every src edge carries, ``identity`` names what else the compile + depends on, the compiler included, and ``cxx`` is what the host rule + asks. Returns the ``cxx_override`` for ``compile_edges``, or None + without a pch. + """ + if not pch_usable(cxx): + return None + if any( + tok == "-include" or tok.startswith("--include") or is_joined_include(tok) + for tok in cxxflags + ): + # $cxxflags expands first and GCC only loads a .gch for the first + # -include + _LOGGER.warning( + "A -include in the compiler flags prevents the precompiled header from " + "loading; compiling without it" + ) + return None + log_pch_in_use() + source = write_pch_headers(build_dir, headers) + write_file_if_changed( + build_dir / PCH_SUM_NAME, + pch_identity([*cxxflags, *src_flags], src_dir, tuple(headers), identity) + "\n", + ) + gch = _e(PCH_GCH_NAME) + # The checksum file changes with anything the .gch depends on + lines.append(f"build {gch}: pch {_e(source)} | {_e(PCH_SUM_NAME)}") + if src_flags: + lines.append(f" flags = {' '.join(src_flags)}") + lines.append(f"srccxxflags = {' '.join([*src_flags, *pch_consumer_flags()])}") + return ("$srccxxflags", gch) + + +def ar_rule_lines(ar: Path | str) -> list[str]: + return [ + "rule ar", + f" command = $python $buildtool ar {_q(ar)} $out $out.rsp", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = AR $out", + ] + + +def compile_edges( + lines: list[str], + sources: list[Path], + root: Path, + group: str, + flags: str = "", + own_includes: str = "", + cxx_override: tuple[str, str] | None = None, +) -> list[str]: + """Emit compile edges for ``sources``; return the object paths. + + ``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges + only, replacing ``flags`` (used for the precompiled header). + """ + objects = [] + for src in sources: + rel = src.relative_to(root).as_posix() + obj = f"obj/{group}/{rel}.o" + escaped_obj = _e(obj) + kind = SOURCE_KIND_FOR_SUFFIX[src.suffix] + override = cxx_override if kind == "cxx" else None + implicit = f" | {override[1]}" if override else "" + lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}") + if own_includes: + lines.append(f" own_includes = {own_includes}") + if edge_flags := override[0] if override else flags: + lines.append(f" flags = {edge_flags}") + # Escaped once here: the returned paths only ever appear in build + # statements (archive/link inputs), which use ninja escaping + objects.append(escaped_obj) + return objects + + +def library_edges( + lines: list[str], libraries: list[ArduinoLibrary] +) -> tuple[list[str], list[str]]: + """Emit every library's compile and archive edges. + + Returns the archive names and the objects that link directly. A + library's own include dirs lead its compile lines, as PlatformIO searched + them first: the include path is one global list, so another library's + header of the same name would shadow them. + """ + archives: list[str] = [] + direct_objs: list[str] = [] + for lib in libraries: + if not lib.sources: + # Header-only libraries are legitimate; the log makes an empty + # srcFilter or broken tree traceable before link errors do + _LOGGER.debug( + "Library %s has no source files; contributing includes only", + lib.name, + ) + continue + objs = compile_edges( + lines, + lib.sources, + common_parent(lib.sources), + f"lib/{lib.name}", + flags=" ".join(_shell_token(f) for f in lib.flags), + own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), + ) + if not lib.lib_archive: + # libArchive: false / dot_a_linkage=false: hand the objects to + # the linker directly so unreferenced-but-required symbols + # (exception handlers, weak overrides) survive + direct_objs.extend(objs) + continue + archive = f"lib{lib.name}.a" + lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") + archives.append(archive) + return archives, direct_objs + + +# One build flag: the flag, plus its argument when that is a separate token +Flag = tuple[str, ...] + +# Flags whose path operand is the next token; gcc also takes it glued on +PATH_ARG_FLAGS = ("-include", "-imacros", "-isystem", "-iquote", "-idirafter") +# Flags whose path operand is glued on +PATH_PREFIXES = ("-I", "-L", *PATH_ARG_FLAGS) + + +def _anchor(path: str, base: Path) -> str: + if not path or Path(path).is_absolute(): + return path + return str(base / path) + + +def anchor_path_flag(flag: Flag, base: Path) -> Flag: + """Anchor a flag's relative path operand at ``base``. + + PlatformIO ran the compiler from the build path; ninja runs it from + ``.pioenvs/``, where a relative operand would point elsewhere. + """ + name, *args = flag + if args: + if name in PATH_ARG_FLAGS: + return (name, _anchor(args[0], base)) + return flag + for prefix in PATH_PREFIXES: + if name.startswith(prefix): + return (prefix + _anchor(name[len(prefix) :], base),) + return flag diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py new file mode 100644 index 00000000000..c2e04c9c651 --- /dev/null +++ b/esphome/build_helpers/pch.py @@ -0,0 +1,248 @@ +"""Shared precompiled header policy for the build backends.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +import hashlib +import logging +import os +from pathlib import Path +import posixpath +import re +import subprocess +import sys + +from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env +from esphome.const import PLATFORM_NRF52 +from esphome.helpers import write_file_if_changed + +_LOGGER = logging.getLogger(__name__) + +# The header and its sidecars live in the build directory +PCH_HEADER_NAME = "esphome_pch.h" +PCH_GCH_NAME = f"{PCH_HEADER_NAME}.gch" +# ccache hashes this instead of the .gch; also the freshness stamp +PCH_SUM_NAME = f"{PCH_GCH_NAME}.sum" +# The include list the .gch is compiled from +PCH_SOURCE_NAME = "esphome_pch_src.h" + +# GCC can skip a .gch without a diagnostic and read the header of the same +# name, so that header is an error. Other tools get the include list. +PCH_GUARD_TEXT = f"""\ +#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__) +#error "The precompiled header was not loaded" +#else +#include "{PCH_SOURCE_NAME}" +#endif +""" + +# The cc1plus wrapper the PlatformIO script writes on arm64 macOS +PCH_CC1_DIR = "pch_cc1" + +# What the PlatformIO script leaves in the project root, for cleanup +PCH_ARTIFACT_NAMES = (PCH_HEADER_NAME, PCH_GCH_NAME, PCH_SUM_NAME, PCH_SOURCE_NAME) +PCH_ARTIFACT_DIRS = (PCH_CC1_DIR,) + +# The core headers every backend precompiles +PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) + +# PlatformIO platforms that do not take the pch script +PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset( + { + PLATFORM_NRF52, + } +) + +# What ccache needs to cache compiles that load a .gch +_CCACHE_PCH_SLOPPINESS = ("pch_defines", "time_macros") + +# Both include forms: an angle include resolving under src/ enters the digest +_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 pch_forced() -> bool: + """``ESPHOME_PCH_ENABLE=1``: wanted even where the host rule says no.""" + return parse_enable_env("ESPHOME_PCH_ENABLE") is True + + +# GCC bug 14940: before these releases the Windows loader maps a .gch only +# at its saved address. First fixed release per major, 16 on always fixed; +# PCH_WINDOWS_CMAKE_OLD_GCC and the pch_usable message spell the same table +PCH_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)} +PCH_WINDOWS_GCC_FIXED_DEFAULT = (16, 0) +# The same rule for CMake, which alone knows the version before configure +PCH_WINDOWS_CMAKE_OLD_GCC = ( + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR " + "(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND " + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)" +) + + +def gcc_relocates_pch_on_windows(version: Sequence[int]) -> bool: + """Whether a GCC of this version loads a .gch on Windows.""" + if not version: + return False + fixed = PCH_WINDOWS_GCC_FIXED.get(version[0], PCH_WINDOWS_GCC_FIXED_DEFAULT) + return tuple(version[:2]) >= fixed + + +# GCC ends the first --version line with its version; clang names itself +_VERSION_RE = re.compile(r"\d+(?:\.\d+)+") + + +def gcc_version(cxx: Sequence[Path | str]) -> tuple[int, ...] | None: + """The GCC version from ``--version``: () when it cannot be read, None + for a compiler that is not GCC.""" + try: + result = subprocess.run( + [*cxx, "--version"], capture_output=True, text=True, check=False + ) + except OSError as err: + _LOGGER.debug("Cannot run %s: %s", cxx[0], err) + return () + banner = result.stdout.partition("\n")[0] + if "clang" in banner.lower(): + return None + found = _VERSION_RE.findall(banner) + return tuple(int(part) for part in found[-1].split(".")) if found else () + + +def pch_needs_gcc_check() -> bool: + """Windows host with the knob unset: the compiler version decides.""" + return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None + + +def pch_usable(cxx: Sequence[Path | str]) -> bool: + """The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule.""" + if not pch_enabled(): + return False + if not pch_needs_gcc_check(): + return True + version = gcc_version(cxx) + if version is None or gcc_relocates_pch_on_windows(version): + return True + _LOGGER.info( + "GCC %s cannot load a precompiled header on Windows (GCC bug 14940, " + "fixed in 14.4, 15.3 and 16); compiling without it " + "(set ESPHOME_PCH_ENABLE=1 to force)", + ".".join(map(str, version)) or "of unknown version", + ) + return False + + +def pch_consumer_flags() -> list[str]: + """Flags a C++ src compile loads the pch with. The -include stays + relative: an absolute path would enter the ccache key.""" + return ["-Winvalid-pch", "-Werror=invalid-pch", "-include", PCH_HEADER_NAME] + + +def ccache_pch_env() -> dict[str, str]: + """What ccache needs to cache compiles that load a .gch, added to what + the user already set.""" + if not pch_enabled(): + return {} + sloppiness = [ + item.strip() + for item in os.environ.get("CCACHE_SLOPPINESS", "").split(",") + if item.strip() + ] + sloppiness += [item for item in _CCACHE_PCH_SLOPPINESS if item not in sloppiness] + env = {"CCACHE_SLOPPINESS": ",".join(sloppiness)} + if "CCACHE_PCH_EXTSUM" not in os.environ: + env["CCACHE_PCH_EXTSUM"] = "true" + return env + + +def pch_script_enabled() -> bool: + """Whether this PlatformIO build takes the pch script.""" + from esphome.core import CORE + + return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS + + +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 write_pch_headers(build_dir: Path, include_headers: Iterable[str]) -> Path: + """Write the guard header and the include list; return the latter, + which is what the .gch compiles from.""" + write_file_if_changed(build_dir / PCH_HEADER_NAME, PCH_GUARD_TEXT) + source = build_dir / PCH_SOURCE_NAME + write_file_if_changed(source, pch_header_text(include_headers)) + return source + + +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). No + #ifdef evaluation: including too much is the safe direction. Headers + outside ``src_dir`` are covered by the version strings of the caller. + """ + 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 (src_dir / rel).is_file(): + break + else: + continue + if rel in seen: + continue + data = seen[rel] = (src_dir / rel).read_bytes() + parent = posixpath.dirname(rel) + stack.extend((inc.decode(), 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 of the prefix header's include closure plus ``extra``.""" + digest = hashlib.sha256() + closure = _include_closure(src_dir, include_headers) + for name in sorted(closure): + digest.update(name.encode()) + digest.update(closure[name]) + digest.update(b"\0") + for item in extra: + digest.update(item.encode()) + digest.update(b"\0") + return digest.hexdigest() + + +def pch_identity( + tokens: Iterable[str], + src_dir: Path, + include_headers: tuple[str, ...], + extra: Iterable[str], +) -> str: + """The .sum digest: include closure, header text, ``extra`` and the + compile flags with the build path stripped, as ccache does.""" + from esphome.core import CORE + + flags = ( + " ".join(tokens) + .replace(str(CORE.build_path), "") + .replace(effective_ccache_basedir(), "") + ) + # The closure is sorted, so header order only enters via the text + return pch_checksum( + src_dir, include_headers, (pch_header_text(include_headers), *extra, flags) + ) + + +_DISABLE_HINT = " (set ESPHOME_PCH_ENABLE=0 to disable)" + + +def log_pch_in_use() -> None: + _LOGGER.info("Compiling with a precompiled header%s", _DISABLE_HINT) diff --git a/esphome/build_helpers/tool_runner.py b/esphome/build_helpers/tool_runner.py new file mode 100644 index 00000000000..52cd6c937a9 --- /dev/null +++ b/esphome/build_helpers/tool_runner.py @@ -0,0 +1,180 @@ +"""Run a native build tool (cmake, ninja) and relay its output. + +Output is read from a pipe so it can be filtered here: a child that inherits +our stdout writes straight to the file descriptor, past any Python wrapper. +""" + +from __future__ import annotations + +import codecs +from contextlib import suppress +import logging +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any, TextIO + +from esphome.util import ANSI_ESCAPE, RedirectText, shlex_quote + +_LOGGER = logging.getLogger(__name__) + +# Windows code page identifier for UTF-8, as used by ``chcp 65001``. +UTF8_CODEPAGE = 65001 + +# Same pattern idf.py uses to spot ninja status lines (``is_progression``). +_PROGRESS = re.compile(r"^\[\d+/\d+\]|.*\(\d+ \%\)$") +_READ_SIZE = 65536 + + +def _get_kernel32() -> Any | None: + """Return the Windows kernel32 module, or None on any other platform.""" + if sys.platform != "win32": + return None + import ctypes + + return ctypes.windll.kernel32 + + +class Utf8Console: + """Keep an attached Windows console on UTF-8 while a build tool runs. + + esp_idf_size draws its table with Unicode box characters, and CMake + re-decodes a child's output with the console code page, which garbles the + table on any page but UTF-8. A console already on UTF-8 is left alone so + an overlapping build never records UTF-8 as the page to go back to. + """ + + def __init__(self, kernel32: Any | None) -> None: + self._kernel32 = kernel32 + self._codepages: tuple[int, int] | None = None + + def __enter__(self) -> None: + kernel32 = self._kernel32 + if kernel32 is None: + return + old_in = kernel32.GetConsoleCP() + old_out = kernel32.GetConsoleOutputCP() + # Both calls return 0 when no console is attached. + if not old_in or not old_out: + return + if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: + return + # Record first so a switch that fails part way is still undone. + self._codepages = (old_in, old_out) + kernel32.SetConsoleCP(UTF8_CODEPAGE) + kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) + + def __exit__(self, *exc_info: object) -> None: + if self._codepages is None: + return + old_in, old_out = self._codepages + self._codepages = None + self._kernel32.SetConsoleCP(old_in) + self._kernel32.SetConsoleOutputCP(old_out) + + +def _fit_terminal(text: str) -> str: + """Elide the middle of ``text`` to fit the terminal, as idf.py does. + + A width of 0 (a pipe, the dashboard) leaves the text whole. + """ + width = shutil.get_terminal_size((0, 0)).columns + if not width: + return text + if width <= 3: + return "." * width + if len(text) >= width: + keep = (width - 3) // 2 + return f"{text[:keep]}...{text[len(text) - keep :]}" + return text + + +class ToolOutput(RedirectText): + """RedirectText that can collapse ninja status lines into one line. + + With ``progress`` each ``[n/m]`` line overwrites the previous one, the + way idf.py shows a build. + """ + + def __init__( + self, out: TextIO, filter_lines: list[str] | None, progress: bool + ) -> None: + super().__init__(out, filter_lines=filter_lines) + self._progress = progress + self._on_progress_line = False + + def _splits_lines(self) -> bool: + return self._progress or super()._splits_lines() + + def _emit_line(self, line: str) -> None: + if self._progress and _PROGRESS.match(line): + if not self._is_filtered(line): + text = _fit_terminal(line.strip("\r\n")) + self._write_color_replace(f"\r{text}\x1b[K") + self._on_progress_line = True + return + self._end_progress_line() + super()._emit_line(line) + + def _end_progress_line(self) -> None: + if self._on_progress_line: + self._on_progress_line = False + self._write_color_replace(os.linesep) + + def drain(self) -> None: + super().drain() + # Called from cleanup, so a broken stream must not hide the exit code. + with suppress(OSError, ValueError): + self._end_progress_line() + self._out.flush() + + +def run_build_tool( + cmd: list[str], + *, + cwd: Path, + env: dict[str, str], + filter_lines: list[str] | None = None, + progress: bool = False, + log_path: Path | None = None, +) -> int: + """Run ``cmd`` and relay stdout and stderr, merged, to our stdout. + + ``log_path`` also gets the full, unfiltered output without color codes, as + idf.py wrote its logs (its hint patterns expect plain text). Returns the + exit code. + """ + _LOGGER.debug("Running: %s", " ".join(shlex_quote(arg) for arg in cmd)) + _LOGGER.debug(" in directory: %s", cwd) + output = ToolOutput(sys.stdout, filter_lines, progress) + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + if log_path is not None: + log_path.parent.mkdir(parents=True, exist_ok=True) + with ( + Path(log_path or os.devnull).open("w", encoding="utf-8", newline="") as log, + Utf8Console(_get_kernel32()), + subprocess.Popen( + cmd, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + close_fds=False, + ) as proc, + ): + try: + # read1 returns as soon as anything is available, so output + # streams while the tool runs. + while chunk := proc.stdout.read1(_READ_SIZE): + text = decoder.decode(chunk) + log.write(ANSI_ESCAPE.sub("", text)) + output.write(text) + if tail := decoder.decode(b"", final=True): + log.write(ANSI_ESCAPE.sub("", tail)) + output.write(tail) + finally: + output.drain() + return proc.returncode diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py index e7193a8e2af..85036c166fe 100644 --- a/esphome/build_helpers/tools_cache.py +++ b/esphome/build_helpers/tools_cache.py @@ -16,8 +16,8 @@ def tools_cache_path(env_var: str, subdir: str) -> Path: from esphome.helpers import get_str_env if prefix := get_str_env(env_var, "").strip(): - # resolve(): symlinked prefixes otherwise trip idf.py's - # venv-mismatch warning on every build + # resolve(): a symlinked prefix would otherwise record a second + # spelling of the same paths in the build tree return Path(prefix).expanduser().resolve() # appauthor=False keeps the Windows path short (no vendor segment); # deep IDF trees run into MAX_PATH otherwise @@ -33,4 +33,14 @@ def tools_cache_path(env_var: str, subdir: str) -> Path: IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf") SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf") ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266") -TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE) +# The host backend installs nothing; the entry only holds its ccache dir +HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host") +# PlatformIO installs into its own dirs; the entry is its ccache dir itself +PLATFORMIO_CCACHE = ("ESPHOME_PLATFORMIO_CCACHE_DIR", "platformio-ccache") +TOOLS_CACHE_SPECS = ( + IDF_TOOLS_CACHE, + SDK_NRF_TOOLS_CACHE, + ARDUINO8266_TOOLS_CACHE, + HOST_TOOLS_CACHE, + PLATFORMIO_CCACHE, +) diff --git a/esphome/cli/__init__.py b/esphome/cli/__init__.py new file mode 100644 index 00000000000..fa6e1fa266c --- /dev/null +++ b/esphome/cli/__init__.py @@ -0,0 +1,2 @@ +"""Commands of the esphome command line, one module each, imported by +__main__ only when they run so that startup stays light.""" diff --git a/esphome/cli/rename.py b/esphome/cli/rename.py new file mode 100644 index 00000000000..cbef68fb70f --- /dev/null +++ b/esphome/cli/rename.py @@ -0,0 +1,164 @@ +"""``esphome rename``.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re + +from esphome import yaml_edit, yaml_util +from esphome.const import ( + ALLOWED_NAME_CHARS, + CONF_ESPHOME, + CONF_NAME, + CONF_SUBSTITUTIONS, +) +from esphome.core import CORE, EsphomeError +from esphome.log import AnsiFore, color +from esphome.types import ConfigType +from esphome.util import ESPHOME_COMMAND, run_external_process, safe_print + + +def _revert(new_path: Path, why: str) -> int: + """Say why the rename stopped and take the new file back; an orphan the + next attempt would trip over is reported.""" + safe_print(color(AnsiFore.BOLD_RED, f"Rename failed: {why}")) + try: + new_path.unlink(missing_ok=True) + except OSError as err: + safe_print(color(AnsiFore.BOLD_RED, f"Could not remove {new_path}: {err}")) + return 1 + + +def command_rename(args: argparse.Namespace, config: ConfigType) -> int | None: + """Rename the device: a new file with the name line rewritten, validated + and installed, then the old file removed.""" + new_name = args.name + for c in new_name: + if c not in ALLOWED_NAME_CHARS: + safe_print( + color( + AnsiFore.BOLD_RED, + f"'{c}' is an invalid character for names. Valid characters are: " + f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)", + ) + ) + return 1 + + yaml = yaml_util.load_yaml(CORE.config_path) + + def name_edit() -> tuple[str, yaml_edit.LineEdit]: + """The name and the line to rewrite: the name's own line, or the + substitution's line it comes from, as a plain value in this file.""" + esphome_conf = yaml.get(CONF_ESPHOME) + if not isinstance(esphome_conf, dict) or CONF_NAME not in esphome_conf: + raise EsphomeError(f"no '{CONF_ESPHOME}: {CONF_NAME}:' in the file") + old_name = str(esphome_conf[CONF_NAME]) + mapping, field = esphome_conf, CONF_NAME + if match := re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name): + mapping, field = yaml.get(CONF_SUBSTITUTIONS), match.group(1) + if not isinstance(mapping, dict) or field not in mapping: + raise EsphomeError(f"the substitution '{field}' is not in the file") + old_name = str(mapping[field]) + # Only read here; the rewritten text goes to a new file, so the + # source may live anywhere the config path points to + source = yaml_edit.source_of(mapping, field) + if source is None or source[0].resolve() != CORE.config_path.resolve(): + raise EsphomeError(f"'{field}' was not read from {CORE.config_path}") + doc, line_no = source + text = yaml_edit.line_at(doc, line_no) + if (line_match := yaml_edit.field_line_re(field, old_name).match(text)) is None: + raise EsphomeError(f"'{field}' is not a plain value on {doc}:{line_no + 1}") + # The new value is always quoted, whatever the old line had + return old_name, yaml_edit.LineEdit( + doc, line_no, text, yaml_edit.rewrite(line_match, new_name, '"') + ) + + try: + old_name, edit = name_edit() + except EsphomeError as err: + safe_print( + color( + AnsiFore.BOLD_RED, + f"Complex YAML files cannot be automatically renamed: {err}", + ) + ) + return 1 + + # ``new_name == old_name`` (after substitution resolution) is + # a no-op rewrite that would still queue a pointless re-flash. + # Catch it before the path-equality check below — covers the + # case where the config filename doesn't match the device name + # (e.g. ``weird-file.yaml`` whose ``esphome.name`` is + # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` + # would otherwise just re-flash the same hostname). + if new_name == old_name: + safe_print( + color( + AnsiFore.BOLD_RED, + f"'{new_name}' is already the device's name.", + ) + ) + return 1 + + new_path: Path = CORE.config_dir / (new_name + ".yaml") + if new_path.resolve() == CORE.config_path.resolve(): + safe_print( + color( + AnsiFore.BOLD_RED, + f"'{new_name}' is already the device's name.", + ) + ) + return 1 + if new_path.exists(): + safe_print( + color( + AnsiFore.BOLD_RED, + f"Cannot rename: {new_path} already exists. " + "Refusing to overwrite an existing configuration.", + ) + ) + return 1 + safe_print( + f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" + ) + print() + + try: + yaml_edit.write_keeping_mode( + new_path, + yaml_edit.rewritten_text(yaml_edit.read_text(CORE.config_path), [edit]), + like=CORE.config_path, + ) + except EsphomeError as err: + return _revert(new_path, str(err)) + + if run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) != 0: + return _revert(new_path, "the new configuration does not validate") + + cli_args = [ + "run", + str(new_path), + "--no-logs", + "--device", + CORE.address, + ] + + if args.dashboard: + cli_args.insert(0, "--dashboard") + + try: + rc = run_external_process(*ESPHOME_COMMAND, *cli_args) + except KeyboardInterrupt: + rc = 1 + if rc != 0: + return _revert( + new_path, + "the install did not finish; the device may already run the new name", + ) + + CORE.config_path.unlink() + + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + print() + return 0 diff --git a/esphome/codegen.py b/esphome/codegen.py index 5debb52b4e2..4de1d8d5c19 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -42,6 +42,7 @@ from esphome.cpp_generator import ( # noqa: F401 set_cpp_standard, statement, static_const_array, + static_function, templatable, variable, with_local_variable, @@ -64,7 +65,6 @@ from esphome.cpp_types import ( # noqa: F401 Application, Component, ComponentPtr, - Controller, EntityBase, EntityCategory, ESPTime, diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py index e701bd98d4e..53a34d1e15f 100644 --- a/esphome/component_aliases.py +++ b/esphome/component_aliases.py @@ -6,5 +6,6 @@ See the component-alias section of esphome/loader.py. # alias -> (canonical component, removal version or None) COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "esp32_improv": ("improv_ble", "2027.4.0"), "rp2040": ("rp2", "2027.7.0"), } diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 71318987479..46b7e7a2ffd 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -94,7 +94,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v /// - SamplingMode::MIN: Use the lowest sample value /// - SamplingMode::MAX: Use the highest sample value /// @param sampling_mode The desired sampling mode to use for aggregating ADC samples. - void set_sampling_mode(SamplingMode sampling_mode); + void set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } /// Perform a single ADC sampling operation and return the measured value. /// This function handles raw readings, calibration, and averaging as needed. diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 5ca58df10e0..70211000c3d 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -76,6 +76,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) { } } -void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } - } // namespace esphome::adc diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 230548ae947..377677ccdb8 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -106,6 +106,20 @@ bool AGS10Component::set_zero_point_with_factory_defaults() { return this->set_z bool AGS10Component::set_zero_point_with_current_resistance() { return this->set_zero_point_with(ZP_CURRENT); } +void AGS10Component::set_zero_point(AGS10SetZeroPointActionMode mode, uint16_t value) { + switch (mode) { + case FACTORY_DEFAULT: + this->set_zero_point_with_factory_defaults(); + break; + case CURRENT_VALUE: + this->set_zero_point_with_current_resistance(); + break; + case CUSTOM_VALUE: + this->set_zero_point_with(value); + break; + } +} + bool AGS10Component::set_zero_point_with(uint16_t value) { std::array data{0x00, 0x0C, (uint8_t) ((value >> 8) & 0xFF), (uint8_t) (value & 0xFF), 0}; data[4] = crc8(data.data(), 4, 0xFF, 0x31, true); diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 8ebc8da544a..8b5f51f9711 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::ags10 { +enum AGS10SetZeroPointActionMode { + // Zero-point reset. + FACTORY_DEFAULT, + // Zero-point calibration with current resistance. + CURRENT_VALUE, + // Zero-point calibration with custom resistance. + CUSTOM_VALUE, +}; + class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** @@ -47,6 +55,11 @@ class AGS10Component final : public PollingComponent, public i2c::I2CDevice { */ bool set_zero_point_with_current_resistance(); + /** + * Sets zero-point by mode; the value is only used for CUSTOM_VALUE. + */ + void set_zero_point(AGS10SetZeroPointActionMode mode, uint16_t value); + /** * Sets zero-point with the value. */ @@ -100,39 +113,4 @@ class AGS10Component final : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, new_address) - - void play(const Ts &...x) override { this->parent_->new_i2c_address(this->new_address_.value(x...)); } -}; - -enum AGS10SetZeroPointActionMode { - // Zero-point reset. - FACTORY_DEFAULT, - // Zero-point calibration with current resistance. - CURRENT_VALUE, - // Zero-point calibration with custom resistance. - CUSTOM_VALUE, -}; - -template class AGS10SetZeroPointAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, value) - TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) - - void play(const Ts &...x) override { - switch (this->mode_.value(x...)) { - case FACTORY_DEFAULT: - this->parent_->set_zero_point_with_factory_defaults(); - break; - case CURRENT_VALUE: - this->parent_->set_zero_point_with_current_resistance(); - break; - case CUSTOM_VALUE: - this->parent_->set_zero_point_with(this->value_.value(x...)); - break; - } - } -}; } // namespace esphome::ags10 diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8606e7c2477..fbd4bf590e3 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,8 +17,6 @@ from esphome.const import ( UNIT_OHM, UNIT_PARTS_PER_BILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CONF_RESISTANCE = "resistance" @@ -28,12 +26,6 @@ DEPENDENCIES = ["i2c"] ags10_ns = cg.esphome_ns.namespace("ags10") AGS10Component = ags10_ns.class_("AGS10Component", cg.PollingComponent, i2c.I2CDevice) -# Actions -AGS10NewI2cAddressAction = ags10_ns.class_( - "AGS10NewI2cAddressAction", automation.Action -) -AGS10SetZeroPointAction = ags10_ns.class_("AGS10SetZeroPointAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -91,24 +83,11 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "ags10.new_i2c_address", - AGS10NewI2cAddressAction, AGS10_NEW_I2C_ADDRESS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "new_i2c_address", cg.uint8), ) -async def ags10newi2caddress_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]) - address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_new_address(address)) - return var - AGS10SetZeroPointActionMode = ags10_ns.enum("AGS10SetZeroPointActionMode") AGS10_SET_ZERO_POINT_ACTION_MODE = { @@ -128,24 +107,11 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ags10.set_zero_point", - AGS10SetZeroPointAction, AGS10_SET_ZERO_POINT_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_zero_point({}, {})", + ((CONF_MODE, AGS10SetZeroPointActionMode), (CONF_VALUE, cg.uint16)), + ), ) -async def ags10setzeropoint_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]) - mode = await cg.templatable( - config.get(CONF_MODE), args, AGS10SetZeroPointActionMode - ) - cg.add(var.set_mode(mode)) - value = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(value)) - return var diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index 50e2f81f1b6..b4942fe3908 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,8 +4,6 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -14,7 +12,6 @@ DEPENDENCIES = ["i2c"] aic3204_ns = cg.esphome_ns.namespace("aic3204") AIC3204 = aic3204_ns.class_("AIC3204", AudioDac, cg.Component, i2c.I2CDevice) -SetAutoMuteAction = aic3204_ns.class_("SetAutoMuteAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -36,25 +33,11 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "aic3204.set_auto_mute_mode", - SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MODE, "set_auto_mute_mode", cg.uint8), ) -async def aic3204_set_volume_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8) - cg.add(var.set_auto_mute_mode(template_)) - - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h deleted file mode 100644 index f0f88566145..00000000000 --- a/esphome/components/aic3204/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "aic3204.h" - -namespace esphome::aic3204 { - -template class SetAutoMuteAction final : public Action { - public: - explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} - - TEMPLATABLE_VALUE(uint8_t, auto_mute_mode) - - void play(const Ts &...x) override { this->aic3204_->set_auto_mute_mode(this->auto_mute_mode_.value(x...)); } - - protected: - AIC3204 *aic3204_; -}; - -} // namespace esphome::aic3204 diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index 58fde11a3d2..2cec58934d6 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -85,20 +85,11 @@ async def wave_base_to_code(var: MockObj, config: ConfigType) -> None: await ble_client.register_ble_node(var, config) - if config_humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(config_humidity) - cg.add(var.set_humidity(sens)) - if config_temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(config_temperature) - cg.add(var.set_temperature(sens)) - if config_pressure := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(config_pressure) - cg.add(var.set_pressure(sens)) - if config_tvoc := config.get(CONF_TVOC): - sens = await sensor.new_sensor(config_tvoc) - cg.add(var.set_tvoc(sens)) - if config_battery_voltage := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(config_battery_voltage) - cg.add(var.set_battery_voltage(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_TVOC, var.set_tvoc) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) if config_battery_update_interval := config.get(CONF_BATTERY_UPDATE_INTERVAL): cg.add(var.set_battery_update_interval(config_battery_update_interval)) diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index 8ea79e644fc..75c1eca594f 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -87,16 +87,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await airthings_wave_base.wave_base_to_code(var, config) - if config_radon := config.get(CONF_RADON): - sens = await sensor.new_sensor(config_radon) - cg.add(var.set_radon(sens)) - if config_radon_long_term := config.get(CONF_RADON_LONG_TERM): - sens = await sensor.new_sensor(config_radon_long_term) - cg.add(var.set_radon_long_term(sens)) - if config_co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(config_co2) - cg.add(var.set_co2(sens)) - if config_illuminance := config.get(CONF_ILLUMINANCE): - sens = await sensor.new_sensor(config_illuminance) - cg.add(var.set_illuminance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADON, var.set_radon) + await sensors(CONF_RADON_LONG_TERM, var.set_radon_long_term) + await sensors(CONF_CO2, var.set_co2) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) cg.add(var.set_device_type(config[CONF_DEVICE_TYPE])) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 2f5d4c7c2bf..78206ff3f8a 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -41,18 +41,6 @@ StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action) -ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action) -ArmNightAction = alarm_control_panel_ns.class_("ArmNightAction", automation.Action) -DisarmAction = alarm_control_panel_ns.class_("DisarmAction", automation.Action) -PendingAction = alarm_control_panel_ns.class_("PendingAction", automation.Action) -TriggeredAction = alarm_control_panel_ns.class_("TriggeredAction", automation.Action) -ChimeAction = alarm_control_panel_ns.class_("ChimeAction", automation.Action) -ReadyAction = alarm_control_panel_ns.class_("ReadyAction", automation.Action) - -AlarmControlPanelCondition = alarm_control_panel_ns.class_( - "AlarmControlPanelCondition", automation.Condition -) _ALARM_CONTROL_PANEL_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) @@ -196,125 +184,38 @@ async def new_alarm_control_panel(config, *args): return var -@automation.register_action( - "alarm_control_panel.arm_away", - ArmAwayAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.arm_home", - ArmHomeAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.arm_night", - ArmNightAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_CODE in config: - templatable_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.disarm", - DisarmAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_disarm_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.pending", - PendingAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_pending_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "alarm_control_panel.triggered", - TriggeredAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_trigger_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "alarm_control_panel.chime", - ChimeAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_chime_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "alarm_control_panel.ready", - ReadyAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_condition( - "alarm_control_panel.ready", - AlarmControlPanelCondition, - ALARM_CONTROL_PANEL_CONDITION_SCHEMA, -) -async def alarm_action_ready_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_condition( - "alarm_control_panel.is_armed", - AlarmControlPanelCondition, - ALARM_CONTROL_PANEL_CONDITION_SCHEMA, -) -async def alarm_control_panel_is_armed_to_code( - config, condition_id, template_arg, args +# Mirrors AlarmControlPanel::arm_with_code_: arm first, set the code only when given. +for _name, _arm in ( + ("alarm_control_panel.arm_away", "arm_away()"), + ("alarm_control_panel.arm_home", "arm_home()"), + ("alarm_control_panel.arm_night", "arm_night()"), + ("alarm_control_panel.disarm", "disarm()"), ): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) + automation.register_apply_action( + _name, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + automation.ApplyCall(_arm), + automation.ApplyField(CONF_CODE, "set_code", cg.std_string), + call="make_call", + ) + + +for _name, _call in ( + ("alarm_control_panel.pending", "pending()"), + ("alarm_control_panel.triggered", "triggered()"), +): + automation.register_apply_action( + _name, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + automation.ApplyCall(_call), + call="make_call", + ) + + +for _name in ("alarm_control_panel.ready", "alarm_control_panel.is_armed"): + automation.register_apply_condition( + _name, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, "is_armed_pending_or_triggered()" + ) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index e748b8621b9..1215044ca10 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -130,6 +130,12 @@ class AlarmControlPanel : public EntityBase { // is the state one of the armed states bool is_state_armed(AlarmControlPanelState state); + /// Armed, pending (entry delay) or triggered; not ARMING (exit delay). Backs the is_armed and ready + /// conditions. + bool is_armed_pending_or_triggered() { + auto state = this->get_state(); + return this->is_state_armed(state) || state == ACP_STATE_PENDING || state == ACP_STATE_TRIGGERED; + } protected: friend AlarmControlPanelCall; @@ -138,11 +144,11 @@ class AlarmControlPanel : public EntityBase { // in order to store last panel state in flash ESPPreferenceObject pref_; // current state - AlarmControlPanelState current_state_; + AlarmControlPanelState current_state_{ACP_STATE_DISARMED}; // the desired (or previous) state - AlarmControlPanelState desired_state_; + AlarmControlPanelState desired_state_{ACP_STATE_DISARMED}; // last time the state was updated - uint32_t last_update_; + uint32_t last_update_{0}; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; // state callback - passes the new state to listeners diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index dcb5121c60f..86bdac37ec8 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,84 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction final : public Action { - public: - explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_away(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class ArmHomeAction final : public Action { - public: - explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_home(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class ArmNightAction final : public Action { - public: - explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_night(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class DisarmAction final : public Action { - public: - explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->disarm(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class PendingAction final : public Action { - public: - explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - void play(const Ts &...x) override { this->alarm_control_panel_->make_call().pending().perform(); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class TriggeredAction final : public Action { - public: - explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - void play(const Ts &...x) override { this->alarm_control_panel_->make_call().triggered().perform(); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class AlarmControlPanelCondition final : public Condition { - public: - AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} - bool check(const Ts &...x) override { - return this->parent_->is_state_armed(this->parent_->get_state()) || - this->parent_->get_state() == ACP_STATE_PENDING || this->parent_->get_state() == ACP_STATE_TRIGGERED; - } - - protected: - AlarmControlPanel *parent_; -}; - } // namespace esphome::alarm_control_panel diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index 2c1a04ef274..d8d59242fac 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -74,26 +74,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await ble_client.register_ble_node(var, config) - if flow_config := config.get(CONF_FLOW): - sens = await sensor.new_sensor(flow_config) - cg.add(var.set_flow_sensor(sens)) - - if head_config := config.get(CONF_HEAD): - sens = await sensor.new_sensor(head_config) - cg.add(var.set_head_sensor(sens)) - - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - - if speed_config := config.get(CONF_SPEED): - sens = await sensor.new_sensor(speed_config) - cg.add(var.set_speed_sensor(sens)) - - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FLOW, var.set_flow_sensor) + await sensors(CONF_HEAD, var.set_head_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_SPEED, var.set_speed_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index 64cddbf09c9..4ee060c1213 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -1,8 +1,6 @@ #pragma once #include "esphome/components/image/image.h" -#include "esphome/core/automation.h" - namespace esphome::animation { class Animation final : public image::Image { @@ -35,32 +33,4 @@ class Animation final : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction final : public Action { - public: - AnimationNextFrameAction(Animation *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->next_frame(); } - - protected: - Animation *parent_; -}; - -template class AnimationPrevFrameAction final : public Action { - public: - AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->prev_frame(); } - - protected: - Animation *parent_; -}; - -template class AnimationSetFrameAction final : public Action { - public: - AnimationSetFrameAction(Animation *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, frame) - void play(const Ts &...x) override { this->parent_->set_frame(this->frame_.value(x...)); } - - protected: - Animation *parent_; -}; - } // namespace esphome::animation diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 0265a350f71..09e0e8753cf 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -6,8 +6,6 @@ from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] @@ -26,17 +24,6 @@ animation_ns = cg.esphome_ns.namespace("animation") Animation_ = animation_ns.class_("Animation", Image_) -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) -) - ANIMATION_SCHEMA = image_schema(Animation_).extend( { cv.Optional(CONF_LOOP): cv.All( @@ -72,28 +59,17 @@ SET_FRAME_SCHEMA = cv.Schema( ) -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.next_frame", NEXT_FRAME_SCHEMA, automation.ApplyCall("next_frame()") ) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.prev_frame", PREV_FRAME_SCHEMA, automation.ApplyCall("prev_frame()") ) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.set_frame", + SET_FRAME_SCHEMA, + automation.ApplyField(CONF_FRAME, "set_frame", cg.uint16), ) -async def animation_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var async def setup_animation(config: ConfigType) -> None: diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 6e382872e2b..b0769bb622e 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); } void Anova::setup() { this->codec_ = make_unique(); - this->current_request_ = 0; + this->poll_step_ = PollStep::IDLE; } void Anova::loop() { @@ -22,6 +22,15 @@ void Anova::loop() { this->disable_loop(); } +void Anova::write_request_(AnovaPacket *pkt) { + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, + pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); + } +} + void Anova::control(const ClimateCall &call) { auto mode_val = call.get_mode(); if (mode_val.has_value()) { @@ -38,22 +47,11 @@ void Anova::control(const ClimateCall &call) { ESP_LOGW(TAG, "Unsupported mode: %d", mode); return; } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(pkt); } auto target_temp = call.get_target_temperature(); if (target_temp.has_value()) { - auto *pkt = this->codec_->get_set_target_temp_request(*target_temp); - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(this->codec_->get_set_target_temp_request(*target_temp)); } } @@ -62,6 +60,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ case ESP_GATTC_DISCONNECT_EVT: { this->current_temperature = NAN; this->target_temperature = NAN; + this->poll_step_ = PollStep::IDLE; this->publish_state(); break; } @@ -83,8 +82,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->node_state = espbt::ClientState::ESTABLISHED; - this->current_request_ = 0; - this->update(); + this->poll_step_ = PollStep::IDLE; + this->update(); // begin the first poll cycle immediately break; } case ESP_GATTC_NOTIFY_EVT: { @@ -101,33 +100,30 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF; } if (this->codec_->has_unit()) { - this->fahrenheit_ = (this->codec_->unit_ == 'f'); - ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius"); - this->current_request_++; + ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius"); } this->publish_state(); - if (this->current_request_ > 1) { - AnovaPacket *pkt = nullptr; - switch (this->current_request_++) { - case 2: - pkt = this->codec_->get_read_target_temp_request(); - break; - case 3: - pkt = this->codec_->get_read_current_temp_request(); - break; - default: - this->current_request_ = 1; - break; - } - if (pkt != nullptr) { - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - } + // Advance the poll cycle to its next request based on the reply we got. + switch (this->poll_step_) { + case PollStep::SET_UNIT: + this->poll_step_ = PollStep::STATUS; + this->write_request_(this->codec_->get_read_device_status_request()); + break; + case PollStep::STATUS: + this->poll_step_ = PollStep::TARGET; + this->write_request_(this->codec_->get_read_target_temp_request()); + break; + case PollStep::TARGET: + this->poll_step_ = PollStep::CURRENT; + this->write_request_(this->codec_->get_read_current_temp_request()); + break; + case PollStep::CURRENT: + this->poll_step_ = PollStep::IDLE; // full cycle complete + break; + default: + // A reply to an ad-hoc control() write, outside a managed cycle. + break; } break; } @@ -136,27 +132,26 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } } -void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); } +void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); } void Anova::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) return; - - if (this->current_request_ < 2) { - AnovaPacket *pkt; - if (this->current_request_ == 0) { - pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); - } else { - pkt = this->codec_->get_read_device_status_request(); - } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - this->current_request_++; + if (this->poll_step_ != PollStep::IDLE) { + // The previous cycle never finished within a full polling interval -- a + // reply was missed or a write failed. Restart the cycle rather than stall; + // the polling interval itself acts as the timeout. A late reply from the + // abandoned cycle is harmless: state decoding happens on every notify + // regardless of step, and each notify sends at most one follow-up request. + ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(), + static_cast(this->poll_step_)); } + // Re-assert the configured unit at the start of every poll cycle, then fall + // through the status/temperature reads via the notification handler. Always + // command the configured unit (want_fahrenheit_) -- never the last value the + // device reported, or a drift to 'c' would lock itself in. + this->poll_step_ = PollStep::SET_UNIT; + this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c')); } } // namespace esphome::anova diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index 49b1100c372..a0fa03df016 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -37,11 +37,20 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie void set_unit_of_measurement(const char *unit); protected: + // A poll cycle re-asserts the configured unit, then reads device state. + // Re-asserting every cycle prevents the cooker from silently reverting to + // its default (Celsius); previously the unit was only set once on + // connection, so a drift persisted (and corrupted the F/C interpretation of + // subsequent readings) until the BLE link was re-established. + enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE }; + + void write_request_(AnovaPacket *pkt); + std::unique_ptr codec_; void control(const climate::ClimateCall &call) override; uint16_t char_handle_; - uint8_t current_request_; - bool fahrenheit_; + bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies + PollStep poll_step_{PollStep::IDLE}; }; } // namespace esphome::anova diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6202e127bfc..2291dc54f8f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -81,7 +81,7 @@ def AUTO_LOAD(config: ConfigType) -> list[str]: api_ns = cg.esphome_ns.namespace("api") -APIServer = api_ns.class_("APIServer", cg.Component, cg.Controller) +APIServer = api_ns.class_("APIServer", cg.Component) HomeAssistantServiceCallAction = api_ns.class_( "HomeAssistantServiceCallAction", automation.Action ) @@ -136,6 +136,12 @@ CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +# Schema defaults that also match the C++ initializers in api_server.h; codegen +# skips the setter when the config equals them. +DEFAULT_PORT = 6053 +DEFAULT_REBOOT_TIMEOUT = "15min" +DEFAULT_BATCH_DELAY = "100ms" + def _register_provisioning_source(config: ConfigType) -> ConfigType: """Register the API as a provisioning source when encryption is enabled. @@ -292,7 +298,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(APIServer), - cv.Optional(CONF_PORT, default=6053): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, # Removed in 2026.1.0 - kept to provide helpful error message cv.Optional(CONF_PASSWORD): cv.invalid( "The 'password' option has been removed in ESPHome 2026.1.0.\n" @@ -305,14 +311,14 @@ CONFIG_SCHEMA = cv.All( "Or visit https://esphome.io/components/api/#configuration-variables" ), cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Exclusive( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, - cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( + cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), @@ -350,10 +356,9 @@ CONFIG_SCHEMA = cv.All( ln882x=5, # Moderate RAM nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), - # Maximum queued send buffers per connection before dropping connection - # Each buffer uses ~8-12 bytes overhead plus actual message size + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast @@ -457,15 +462,20 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) # Request a log listener slot for API log streaming request_log_listener() - cg.add(var.set_port(config[CONF_PORT])) - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + # Skip the setters when the config matches the C++ initializers (DEFAULT_*). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(var.set_port(port)) + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY): + cg.add(var.set_batch_delay(batch_delay)) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3a0e0abea96..687dc1ca951 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -77,6 +77,7 @@ service APIConnection { rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} + rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -802,6 +803,9 @@ message SwitchStateResponse { fixed32 key = 1 [(force) = true]; bool state = 2; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; + // If the switch does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 4; } message SwitchCommandRequest { option (id) = 33; @@ -1243,6 +1247,9 @@ message ClimateStateResponse { float current_humidity = 14; float target_humidity = 15; uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"]; + // If the climate device does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 17; } message ClimateCommandRequest { option (id) = 48; @@ -1329,6 +1336,9 @@ message WaterHeaterStateResponse { uint32 state = 6; float target_temperature_low = 7; float target_temperature_high = 8; + // If the water heater does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 9; } // Bitmask for WaterHeaterCommandRequest.has_fields @@ -2726,7 +2736,8 @@ enum SerialProxyParity { SERIAL_PROXY_PARITY_ODD = 2; } -// Configure UART parameters for a serial proxy instance +// Configure UART parameters for a serial proxy instance. Only the subscribed client may +// configure the port; others are refused with PORT_IN_USE (since API 1.17). message SerialProxyConfigureRequest { option (id) = 138; option (source) = SOURCE_CLIENT; @@ -2752,7 +2763,8 @@ message SerialProxyDataReceived { bytes data = 2; // Raw data received from the serial device } -// Write data to a serial device +// Write data to a serial device. Only the subscribed client may write; writes from +// others are ignored (since API 1.17). message SerialProxyWriteRequest { option (id) = 140; option (source) = SOURCE_CLIENT; @@ -2763,7 +2775,8 @@ message SerialProxyWriteRequest { bytes data = 2; // Raw data to write to the serial device } -// Set modem control pin states (RTS and DTR) +// Set modem control pin states (RTS and DTR). Only the subscribed client may set them; +// others are refused with PORT_IN_USE (since API 1.17). message SerialProxySetModemPinsRequest { option (id) = 141; option (source) = SOURCE_CLIENT; @@ -2802,6 +2815,7 @@ enum SerialProxyRequestType { // error the device answers with INVALID_ARGUMENT. SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17) } enum SerialProxyStatus { @@ -2814,7 +2828,8 @@ enum SerialProxyStatus { SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value } -// Generic request message for simple serial proxy operations +// Generic request message for simple serial proxy operations. FLUSH requires an active +// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17). message SerialProxyRequest { option (id) = 144; option (source) = SOURCE_CLIENT; @@ -2838,6 +2853,29 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL +// activates the port's protocol-aware tap (if one is configured), letting it observe +// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap +// speaks is a property of the device configuration, discoverable from the tap +// component's own API surface. A client that is about to flash firmware selects RAW +// first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_PROTOCOL = 1; +} + +// Only the subscribed client may change the mode; any other caller -- including one that +// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED +// when the port has no protocol-aware tap configured. +message SerialProxySetModeRequest { + option (id) = 152; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index fc45a4e971f..62a544b1a41 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,20 +1,37 @@ #include "api_buffer.h" -#include +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + bool APIBuffer::grow_(size_t n) { - // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead - // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). - // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. - std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); - if (new_data == nullptr) + if (n > MAX_SIZE) return false; - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; return true; } +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; +} + } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 396dadbe587..7caa68aa4d5 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -25,6 +25,7 @@ namespace esphome::api { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths void clear() { this->size_ = 0; } /// Returns false if allocation fails; the buffer is left unchanged. [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } @@ -36,9 +37,19 @@ class APIBuffer { [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { if (!this->reserve(std::max(reserve_size, new_size))) return false; - this->size_ = new_size; + this->size_ = static_cast(new_size); return true; } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } @@ -55,9 +66,14 @@ class APIBuffer { protected: bool grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index da4b7d7702f..0207bc14b9f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); @@ -597,7 +600,7 @@ bool APIConnection::send_light_state(light::LightState *light) { uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *light = static_cast(entity); LightStateResponse resp; - auto values = light->remote_values; + auto values = light->get_reported_values(); auto color_mode = values.get_color_mode(); resp.state = values.is_on(); resp.color_mode = static_cast(color_mode); @@ -706,6 +709,7 @@ uint16_t APIConnection::try_send_switch_state(EntityBase *entity, APIConnection auto *a_switch = static_cast(entity); SwitchStateResponse resp; resp.state = a_switch->state; + resp.missing_state = !a_switch->has_state(); return fill_and_encode_entity_state(a_switch, resp, conn, remaining_size); } @@ -717,12 +721,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) - - if (msg.state) { - a_switch->turn_on(); - } else { - a_switch->turn_off(); - } + a_switch->control(msg.state); } #endif @@ -756,6 +755,7 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection auto traits = climate->get_traits(); resp.mode = static_cast(climate->mode); resp.action = static_cast(climate->action); + resp.missing_state = !climate->has_state(); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) resp.current_temperature = climate->current_temperature; if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | @@ -1449,6 +1449,7 @@ uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConne auto *wh = static_cast(entity); WaterHeaterStateResponse resp; resp.mode = static_cast(wh->get_mode()); + resp.missing_state = !wh->has_state(); resp.current_temperature = wh->get_current_temperature(); resp.target_temperature = wh->get_target_temperature(); resp.target_temperature_low = wh->get_target_temperature_low(); @@ -1661,6 +1662,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: // Response-only discriminators; never valid in a request ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type)); status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; @@ -1673,6 +1675,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { send_serial_proxy_ack(this, msg.instance, msg.type, status); } +void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); + return; + } + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + serial_proxy_result_to_status(result)); +} + void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { if (!this->send_message(msg)) { ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); @@ -1799,7 +1814,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 16; + resp.api_version_minor = 17; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -2255,7 +2270,12 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, // Capacity reserved above, cannot fail (void) shared_buf.resize(write_start + payload_size); ProtoWriteBuffer buffer{&shared_buf, write_start}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); +#ifdef ESPHOME_DEBUG_API + proto_check_encode_end(end, shared_buf.data() + shared_buf.size()); +#else + (void) end; +#endif return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type); } // encode_to_buffer is defined inline in api_connection.h (ESPHOME_ALWAYS_INLINE) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a4c49dccf40..f03c4c42e8f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -244,6 +244,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); #endif @@ -345,11 +346,7 @@ class APIConnection final : public APIServerConnectionBase { /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we /// have no silent failures: every caller must handle (or log) a refusal. template [[nodiscard]] bool send_message(const T &msg) { - if constexpr (T::ESTIMATED_SIZE == 0) { - return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); - } else { - return this->send_message_(msg.calculate_size(), T::MESSAGE_TYPE, &proto_encode_msg, &msg); - } + return this->send_message_(T::calc_size_msg(&msg), T::MESSAGE_TYPE, &T::encode_msg, &msg); } /// Clear the shared write buffer and reserve space for the first message. @@ -405,16 +402,6 @@ class APIConnection final : public APIServerConnectionBase { void process_state_subscriptions_(); #endif - // Size thunk — converts void* back to concrete type for direct calculate_size() call - template static uint32_t calc_size(const void *msg) { - return static_cast(msg)->calculate_size(); - } - - // Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0) - static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { - return buf.get_pos(); - } - // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg); @@ -433,11 +420,7 @@ class APIConnection final : public APIServerConnectionBase { // Hot paths (state/info) go through fill_and_encode_entity_state/info instead. // batch_message_type_ is already set by dispatch_message_ before reaching here. template static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) { - if constexpr (T::ESTIMATED_SIZE == 0) { - return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size); - } else { - return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); - } + return encode_to_buffer_slow(T::calc_size_msg(&msg), &T::encode_msg, &msg, conn, remaining_size); } // Non-template core — fills state fields and encodes @@ -449,7 +432,7 @@ class APIConnection final : public APIServerConnectionBase { template static uint16_t fill_and_encode_entity_state(EntityBase *entity, T &msg, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_state(entity, msg, &calc_size, &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_state(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } // Non-template core — fills info fields, allocates buffers, and encodes @@ -461,7 +444,7 @@ class APIConnection final : public APIServerConnectionBase { template static uint16_t fill_and_encode_entity_info(EntityBase *entity, T &msg, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_info(entity, msg, &calc_size, &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_info(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } // Non-template core — fills device_class, then delegates to fill_and_encode_entity_info @@ -475,8 +458,8 @@ class APIConnection final : public APIServerConnectionBase { static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, T &msg, StringRef &device_class_field, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &calc_size, - &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &T::calc_size_msg, + &T::encode_msg, conn, remaining_size); } #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h index 08520249bf6..5fbe171b523 100644 --- a/esphome/components/api/api_connection_buffer.h +++ b/esphome/components/api/api_connection_buffer.h @@ -46,7 +46,13 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c return 0; } ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); +#ifdef ESPHOME_DEBUG_API + // A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes + proto_check_encode_end(end, shared_buf.data() + shared_buf.size()); +#else + (void) end; +#endif return total_calculated_size; } diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 38da444a189..41d1230aaa6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c0..a68a0ad0d87 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee8..e726a7c9676 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -5,16 +5,13 @@ #include "esphome/components/noise/noise.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "proto.h" #include #include -#ifdef USE_ESP8266 -#include -#endif - namespace esphome::api { using noise::noise_err_to_logstr; @@ -26,11 +23,7 @@ static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE, "api and noise component handshake size limits must match"); static const char *const TAG = "api.noise"; -#ifdef USE_ESP8266 static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; -#else -static const char *const PROLOGUE_INIT = "NoiseAPIInit"; -#endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) @@ -67,16 +60,12 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } -#ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#endif + progmem_memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); state_ = State::CLIENT_HELLO; return APIError::OK; @@ -272,17 +261,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 48d8fe18ba8..0b5a874d4b5 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,98 +1,91 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include -#include namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { - // socket->write() can re-enter this function: a log message emitted from an - // lwip callback during the write goes out over the API and lands back in the - // frame helper's write/drain path. If a nested drain ran here it would send - // and free the entry the outer drain is still holding, causing a double free. - // Report "no progress" instead; the outer drain keeps draining, and the - // nested send is enqueued behind the existing backlog. + // Nested call from inside socket->write(); see draining_ if (this->draining_) return 0; - // RAII so the flag is cleared on every return path struct DrainGuard { - explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } - ~DrainGuard() { this->flag_ = false; } - bool &flag_; - } guard(this->draining_); + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — unlink it before freeing so a freed pointer is never - // reachable from the queue - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; - Entry::destroy(front); } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // nothrow: a failed allocation returns nullptr so the connection is dropped - // cleanly instead of plain new's crash or abort on OOM - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *data = new (std::nothrow) uint8_t[buffer_size]; - if (data == nullptr) - return false; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; - if (entry == nullptr) { - delete[] data; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) return false; + + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; } - uint16_t to_skip = skip; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - // Publish only after the copy completes so a half-built entry is never reachable - this->queue_[this->tail_] = entry; - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 03a334b281a..e2e4b9c3c37 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,71 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// Enqueue unsent IOV data into the backlog. - /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full or allocation fails (caller should fail the connection). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; - // Guards against re-entrant drains: socket->write() can re-enter the API - // send path (e.g. a log message emitted from an lwip callback), and a nested - // drain would free the entry the outer drain is still holding. - bool draining_{false}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2de1f0a15ce..1d1f594aa73 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,1370 +7,1361 @@ namespace esphome::api { -bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->api_version_major = value; +void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.client_info = StringRef(value.data(), value.size()); break; - case 3: - this->api_version_minor = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.api_version_major = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.api_version_minor = value.as_varint(); break; - default: - return false; } - return true; } -bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.name); return pos; } -uint32_t HelloResponse::calculate_size() const { +uint32_t HelloResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->api_version_major); - size += ProtoSize::calc_uint32(1, this->api_version_minor); - size += 2 + this->server_info.size(); - size += 2 + this->name.size(); + size += ProtoSize::calc_uint32(1, msg.api_version_major); + size += ProtoSize::calc_uint32(1, msg.api_version_minor); + size += 2 + msg.server_info.size(); + size += 2 + msg.name.size(); return size; } -bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->reason = static_cast(value); +void DisconnectRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.reason = static_cast(value.as_varint()); break; - default: - return false; } - return true; } -uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DisconnectRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.reason)); return pos; } -uint32_t DisconnectRequest::calculate_size() const { +uint32_t DisconnectRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->reason ? 2 : 0; + size += msg.reason ? 2 : 0; return size; } #ifdef USE_AREAS -uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *AreaInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.area_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); return pos; } -uint32_t AreaInfo::calculate_size() const { +uint32_t AreaInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->area_id); - size += 2 + this->name.size(); + size += ProtoSize::calc_uint32(1, msg.area_id); + size += 2 + msg.name.size(); return size; } #endif #ifdef USE_DEVICES -uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DeviceInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.area_id); return pos; } -uint32_t DeviceInfo::calculate_size() const { +uint32_t DeviceInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->device_id); - size += 2 + this->name.size(); - size += ProtoSize::calc_uint32(1, this->area_id); + size += ProtoSize::calc_uint32(1, msg.device_id); + size += 2 + msg.name.size(); + size += ProtoSize::calc_uint32(1, msg.area_id); return size; } #endif #ifdef USE_SERIAL_PROXY -uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.port_type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.configured_line_states); return pos; } -uint32_t SerialProxyInfo::calculate_size() const { +uint32_t SerialProxyInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += this->port_type ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->configured_line_states); + size += ProtoSize::calc_length(1, msg.name.size()); + size += msg.port_type ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.configured_line_states); return size; } #endif -uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, msg.compilation_time); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); #ifdef USE_DEEP_SLEEP - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); #endif #ifdef USE_WEBSERVER - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.bluetooth_proxy_feature_flags); #endif - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, msg.friendly_name); #ifdef USE_VOICE_ASSISTANT - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.suggested_area); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.bluetooth_mac_address); #endif #ifdef USE_API_NOISE - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.api_encryption_supported); #endif #ifdef USE_DEVICES - for (const auto &it : this->devices) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); + for (const auto &it : msg.devices) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); } #endif #ifdef USE_AREAS - for (const auto &it : this->areas) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); + for (const auto &it : msg.areas) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); } #endif #ifdef USE_AREAS - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, this->area); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, msg.area); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, this->zwave_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, this->zwave_home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, msg.zwave_home_id); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); + for (const auto &it : msg.serial_proxies) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } #endif #ifdef USE_API_NOISE - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.api_encryption_provisionable); #endif return pos; } -uint32_t DeviceInfoResponse::calculate_size() const { +uint32_t DeviceInfoResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->name.size(); - size += 2 + this->mac_address.size(); - size += 2 + this->esphome_version.size(); - size += 2 + this->compilation_time.size(); - size += 2 + this->model.size(); + size += 2 + msg.name.size(); + size += 2 + msg.mac_address.size(); + size += 2 + msg.esphome_version.size(); + size += 2 + msg.compilation_time.size(); + size += 2 + msg.model.size(); #ifdef USE_DEEP_SLEEP - size += ProtoSize::calc_bool(1, this->has_deep_sleep); + size += ProtoSize::calc_bool(1, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size += 2 + this->project_name.size(); + size += 2 + msg.project_name.size(); #endif #ifdef ESPHOME_PROJECT_NAME - size += 2 + this->project_version.size(); + size += 2 + msg.project_version.size(); #endif #ifdef USE_WEBSERVER - size += ProtoSize::calc_uint32(1, this->webserver_port); + size += ProtoSize::calc_uint32(1, msg.webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_uint32(1, this->bluetooth_proxy_feature_flags); + size += ProtoSize::calc_uint32(1, msg.bluetooth_proxy_feature_flags); #endif - size += 2 + this->manufacturer.size(); - size += 2 + this->friendly_name.size(); + size += 2 + msg.manufacturer.size(); + size += 2 + msg.friendly_name.size(); #ifdef USE_VOICE_ASSISTANT - size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); + size += ProtoSize::calc_uint32(2, msg.voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size += 3 + this->suggested_area.size(); + size += 3 + msg.suggested_area.size(); #endif #ifdef USE_BLUETOOTH_PROXY - size += 3 + this->bluetooth_mac_address.size(); + size += 3 + msg.bluetooth_mac_address.size(); #endif #ifdef USE_API_NOISE - size += ProtoSize::calc_bool(2, this->api_encryption_supported); + size += ProtoSize::calc_bool(2, msg.api_encryption_supported); #endif #ifdef USE_DEVICES - for (const auto &it : this->devices) { + for (const auto &it : msg.devices) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_AREAS - for (const auto &it : this->areas) { + for (const auto &it : msg.areas) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_AREAS - size += ProtoSize::calc_message(2, this->area.calculate_size()); + size += ProtoSize::calc_message(2, msg.area.calculate_size()); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_uint32(2, this->zwave_proxy_feature_flags); + size += ProtoSize::calc_uint32(2, msg.zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_uint32(2, this->zwave_home_id); + size += ProtoSize::calc_uint32(2, msg.zwave_home_id); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_API_NOISE - size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); + size += ProtoSize::calc_bool(2, msg.api_encryption_provisionable); #endif return size; } #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothProxyCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.mac_address); return pos; } -uint32_t BluetoothProxyCapabilities::calculate_size() const { +uint32_t BluetoothProxyCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); - size += 2 + this->mac_address.size(); + size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += 2 + msg.mac_address.size(); return size; } #endif #ifdef USE_VOICE_ASSISTANT -uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); return pos; } -uint32_t VoiceAssistantCapabilities::calculate_size() const { +uint32_t VoiceAssistantCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } #endif #ifdef USE_ZWAVE_PROXY -uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ZWaveProxyCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.home_id); return pos; } -uint32_t ZWaveProxyCapabilities::calculate_size() const { +uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); - size += ProtoSize::calc_uint32(1, this->home_id); + size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += ProtoSize::calc_uint32(1, msg.home_id); return size; } #endif -uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, msg.bluetooth_proxy); #endif #ifdef USE_VOICE_ASSISTANT - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, msg.voice_assistant); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, msg.zwave_proxy); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + for (const auto &it : msg.serial_proxies) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } #endif return pos; } -uint32_t DeviceCapabilitiesResponse::calculate_size() const { +uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); + size += ProtoSize::calc_message(1, msg.bluetooth_proxy.calculate_size()); #endif #ifdef USE_VOICE_ASSISTANT - size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); + size += ProtoSize::calc_message(1, msg.voice_assistant.calculate_size()); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); + size += ProtoSize::calc_message(1, msg.zwave_proxy.calculate_size()); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } #endif return size; } #ifdef USE_BINARY_SENSOR -uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->is_status_binary_sensor); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.is_status_binary_sensor); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesBinarySensorResponse::calculate_size() const { +uint32_t ListEntitiesBinarySensorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->is_status_binary_sensor); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.is_status_binary_sensor); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *BinarySensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BinarySensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t BinarySensorStateResponse::calculate_size() const { +uint32_t BinarySensorStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_bool(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_COVER -uint8_t *ListEntitiesCoverResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_position); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_tilt); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.supports_tilt); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_stop); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supports_stop); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id); #endif return pos; } -uint32_t ListEntitiesCoverResponse::calculate_size() const { +uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_position); - size += ProtoSize::calc_bool(1, this->supports_tilt); - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_position); + size += ProtoSize::calc_bool(1, msg.supports_tilt); + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->supports_stop); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.supports_stop); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *CoverStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *CoverStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->position); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->tilt); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->current_operation)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + if (uint32_t raw = float_to_raw(msg.tilt); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(msg.current_operation)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t CoverStateResponse::calculate_size() const { +uint32_t CoverStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->position); - size += ProtoSize::calc_float(1, this->tilt); - size += this->current_operation ? 2 : 0; + size += ProtoSize::calc_float(1, msg.position); + size += ProtoSize::calc_float(1, msg.tilt); + size += msg.current_operation ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 4: - this->has_position = value != 0; +void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 6: - this->has_tilt = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_position = value.as_bool(); break; - case 8: - this->stop = value != 0; + case proto_tag(5, WIRE_TYPE_FIXED32): + msg.position = value.as_float(); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_tilt = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): + msg.tilt = value.as_float(); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.stop = value.as_bool(); break; #ifdef USE_DEVICES - case 9: - this->device_id = value; + case proto_tag(9, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->position = value.as_float(); - break; - case 7: - this->tilt = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_FAN -uint8_t *ListEntitiesFanResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesFanResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_oscillation); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_speed); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_direction); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_speed_count); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_oscillation); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_speed); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.supports_direction); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supported_speed_count); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); - for (const char *it : *this->supported_preset_modes) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 12, it, strlen(it), true); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.entity_category)); + for (const char *it : *msg.supported_preset_modes) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 12, it, strlen(it)); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id); #endif return pos; } -uint32_t ListEntitiesFanResponse::calculate_size() const { +uint32_t ListEntitiesFanResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->supports_oscillation); - size += ProtoSize::calc_bool(1, this->supports_speed); - size += ProtoSize::calc_bool(1, this->supports_direction); - size += ProtoSize::calc_int32(1, this->supported_speed_count); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.supports_oscillation); + size += ProtoSize::calc_bool(1, msg.supports_speed); + size += ProtoSize::calc_bool(1, msg.supports_direction); + size += ProtoSize::calc_int32(1, msg.supported_speed_count); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; - if (!this->supported_preset_modes->empty()) { - for (const char *it : *this->supported_preset_modes) { + size += msg.entity_category ? 2 : 0; + if (!msg.supported_preset_modes->empty()) { + for (const char *it : *msg.supported_preset_modes) { size += ProtoSize::calc_length_force(1, strlen(it)); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *FanStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *FanStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->oscillating); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->direction)); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->speed_level); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->preset_mode); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.oscillating); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(msg.direction)); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.speed_level); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.preset_mode); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t FanStateResponse::calculate_size() const { +uint32_t FanStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); - size += ProtoSize::calc_bool(1, this->oscillating); - size += this->direction ? 2 : 0; - size += ProtoSize::calc_int32(1, this->speed_level); - size += ProtoSize::calc_length(1, this->preset_mode.size()); + size += ProtoSize::calc_bool(1, msg.state); + size += ProtoSize::calc_bool(1, msg.oscillating); + size += msg.direction ? 2 : 0; + size += ProtoSize::calc_int32(1, msg.speed_level); + size += ProtoSize::calc_length(1, msg.preset_mode.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; +void FanCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->state = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_state = value.as_bool(); break; - case 6: - this->has_oscillating = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.state = value.as_bool(); break; - case 7: - this->oscillating = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_oscillating = value.as_bool(); break; - case 8: - this->has_direction = value != 0; + case proto_tag(7, WIRE_TYPE_VARINT): + msg.oscillating = value.as_bool(); break; - case 9: - this->direction = static_cast(value); + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_direction = value.as_bool(); break; - case 10: - this->has_speed_level = value != 0; + case proto_tag(9, WIRE_TYPE_VARINT): + msg.direction = static_cast(value.as_varint()); break; - case 11: - this->speed_level = static_cast(value); + case proto_tag(10, WIRE_TYPE_VARINT): + msg.has_speed_level = value.as_bool(); break; - case 12: - this->has_preset_mode = value != 0; + case proto_tag(11, WIRE_TYPE_VARINT): + msg.speed_level = static_cast(value.as_varint()); + break; + case proto_tag(12, WIRE_TYPE_VARINT): + msg.has_preset_mode = value.as_bool(); + break; + case proto_tag(13, WIRE_TYPE_LENGTH_DELIMITED): + msg.preset_mode = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES - case 14: - this->device_id = value; + case proto_tag(14, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_LIGHT -uint8_t *ListEntitiesLightResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - for (const auto &it : *this->supported_color_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it), true); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + for (const auto &it : *msg.supported_color_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it)); } - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->min_mireds); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->max_mireds); - for (const char *it : *this->effects) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it), true); + if (uint32_t raw = float_to_raw(msg.min_mireds); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, this->disabled_by_default); + if (uint32_t raw = float_to_raw(msg.max_mireds); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + for (const char *it : *msg.effects) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it)); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif return pos; } -uint32_t ListEntitiesLightResponse::calculate_size() const { +uint32_t ListEntitiesLightResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - if (!this->supported_color_modes->empty()) { - size += this->supported_color_modes->size() * 2; + size += 2 + msg.name.size(); + if (!msg.supported_color_modes->empty()) { + size += msg.supported_color_modes->size() * 2; } - size += ProtoSize::calc_float(1, this->min_mireds); - size += ProtoSize::calc_float(1, this->max_mireds); - if (!this->effects->empty()) { - for (const char *it : *this->effects) { + size += ProtoSize::calc_float(1, msg.min_mireds); + size += ProtoSize::calc_float(1, msg.max_mireds); + if (!msg.effects->empty()) { + for (const char *it : *msg.effects) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif return size; } -uint8_t *LightStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *LightStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->brightness); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->color_mode)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->color_brightness); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->red); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->green); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->blue); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->white); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->color_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 12, this->cold_white); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 13, this->warm_white); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->effect); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + if (uint32_t raw = float_to_raw(msg.brightness); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.color_mode)); + if (uint32_t raw = float_to_raw(msg.color_brightness); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + if (uint32_t raw = float_to_raw(msg.red); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + if (uint32_t raw = float_to_raw(msg.green); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + if (uint32_t raw = float_to_raw(msg.blue); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + if (uint32_t raw = float_to_raw(msg.white); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(msg.color_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + if (uint32_t raw = float_to_raw(msg.cold_white); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 101, raw); + } + if (uint32_t raw = float_to_raw(msg.warm_white); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 109, raw); + } + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.effect); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t LightStateResponse::calculate_size() const { +uint32_t LightStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); - size += ProtoSize::calc_float(1, this->brightness); - size += this->color_mode ? 2 : 0; - size += ProtoSize::calc_float(1, this->color_brightness); - size += ProtoSize::calc_float(1, this->red); - size += ProtoSize::calc_float(1, this->green); - size += ProtoSize::calc_float(1, this->blue); - size += ProtoSize::calc_float(1, this->white); - size += ProtoSize::calc_float(1, this->color_temperature); - size += ProtoSize::calc_float(1, this->cold_white); - size += ProtoSize::calc_float(1, this->warm_white); - size += ProtoSize::calc_length(1, this->effect.size()); + size += ProtoSize::calc_bool(1, msg.state); + size += ProtoSize::calc_float(1, msg.brightness); + size += msg.color_mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.color_brightness); + size += ProtoSize::calc_float(1, msg.red); + size += ProtoSize::calc_float(1, msg.green); + size += ProtoSize::calc_float(1, msg.blue); + size += ProtoSize::calc_float(1, msg.white); + size += ProtoSize::calc_float(1, msg.color_temperature); + size += ProtoSize::calc_float(1, msg.cold_white); + size += ProtoSize::calc_float(1, msg.warm_white); + size += ProtoSize::calc_length(1, msg.effect.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; +void LightCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->state = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_state = value.as_bool(); break; - case 4: - this->has_brightness = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.state = value.as_bool(); break; - case 22: - this->has_color_mode = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_brightness = value.as_bool(); break; - case 23: - this->color_mode = static_cast(value); + case proto_tag(5, WIRE_TYPE_FIXED32): + msg.brightness = value.as_float(); break; - case 20: - this->has_color_brightness = value != 0; + case proto_tag(22, WIRE_TYPE_VARINT): + msg.has_color_mode = value.as_bool(); break; - case 6: - this->has_rgb = value != 0; + case proto_tag(23, WIRE_TYPE_VARINT): + msg.color_mode = static_cast(value.as_varint()); break; - case 10: - this->has_white = value != 0; + case proto_tag(20, WIRE_TYPE_VARINT): + msg.has_color_brightness = value.as_bool(); break; - case 12: - this->has_color_temperature = value != 0; + case proto_tag(21, WIRE_TYPE_FIXED32): + msg.color_brightness = value.as_float(); break; - case 24: - this->has_cold_white = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_rgb = value.as_bool(); break; - case 26: - this->has_warm_white = value != 0; + case proto_tag(7, WIRE_TYPE_FIXED32): + msg.red = value.as_float(); break; - case 14: - this->has_transition_length = value != 0; + case proto_tag(8, WIRE_TYPE_FIXED32): + msg.green = value.as_float(); break; - case 15: - this->transition_length = value; + case proto_tag(9, WIRE_TYPE_FIXED32): + msg.blue = value.as_float(); break; - case 16: - this->has_flash_length = value != 0; + case proto_tag(10, WIRE_TYPE_VARINT): + msg.has_white = value.as_bool(); break; - case 17: - this->flash_length = value; + case proto_tag(11, WIRE_TYPE_FIXED32): + msg.white = value.as_float(); break; - case 18: - this->has_effect = value != 0; + case proto_tag(12, WIRE_TYPE_VARINT): + msg.has_color_temperature = value.as_bool(); + break; + case proto_tag(13, WIRE_TYPE_FIXED32): + msg.color_temperature = value.as_float(); + break; + case proto_tag(24, WIRE_TYPE_VARINT): + msg.has_cold_white = value.as_bool(); + break; + case proto_tag(25, WIRE_TYPE_FIXED32): + msg.cold_white = value.as_float(); + break; + case proto_tag(26, WIRE_TYPE_VARINT): + msg.has_warm_white = value.as_bool(); + break; + case proto_tag(27, WIRE_TYPE_FIXED32): + msg.warm_white = value.as_float(); + break; + case proto_tag(14, WIRE_TYPE_VARINT): + msg.has_transition_length = value.as_bool(); + break; + case proto_tag(15, WIRE_TYPE_VARINT): + msg.transition_length = value.as_varint(); + break; + case proto_tag(16, WIRE_TYPE_VARINT): + msg.has_flash_length = value.as_bool(); + break; + case proto_tag(17, WIRE_TYPE_VARINT): + msg.flash_length = value.as_varint(); + break; + case proto_tag(18, WIRE_TYPE_VARINT): + msg.has_effect = value.as_bool(); + break; + case proto_tag(19, WIRE_TYPE_LENGTH_DELIMITED): + msg.effect = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES - case 28: - this->device_id = value; + case proto_tag(28, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->brightness = value.as_float(); - break; - case 21: - this->color_brightness = value.as_float(); - break; - case 7: - this->red = value.as_float(); - break; - case 8: - this->green = value.as_float(); - break; - case 9: - this->blue = value.as_float(); - break; - case 11: - this->white = value.as_float(); - break; - case 13: - this->color_temperature = value.as_float(); - break; - case 25: - this->cold_white = value.as_float(); - break; - case 27: - this->warm_white = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_SENSOR -uint8_t *ListEntitiesSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSensorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->unit_of_measurement); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->accuracy_decimals); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->force_update); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->state_class)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.unit_of_measurement); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.accuracy_decimals); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.force_update); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.state_class)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSensorResponse::calculate_size() const { +uint32_t ListEntitiesSensorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; - size += ProtoSize::calc_int32(1, this->accuracy_decimals); - size += ProtoSize::calc_bool(1, this->force_update); - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += this->state_class ? 2 : 0; - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += !msg.unit_of_measurement.empty() ? 2 + msg.unit_of_measurement.size() : 0; + size += ProtoSize::calc_int32(1, msg.accuracy_decimals); + size += ProtoSize::calc_bool(1, msg.force_update); + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += msg.state_class ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +SensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SensorStateResponse::calculate_size() const { +SensorStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_float(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_SWITCH -uint8_t *ListEntitiesSwitchResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSwitchResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSwitchResponse::calculate_size() const { +uint32_t ListEntitiesSwitchResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SwitchStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SwitchStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.missing_state); return pos; } -uint32_t SwitchStateResponse::calculate_size() const { +uint32_t SwitchStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); + size += ProtoSize::calc_bool(1, msg.state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } -bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->state = value != 0; +void SwitchCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.state = value.as_bool(); break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_TEXT_SENSOR -uint8_t *ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesTextSensorResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTextSensorResponse::calculate_size() const { +uint32_t ListEntitiesTextSensorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TextSensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *TextSensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t TextSensorStateResponse::calculate_size() const { +uint32_t TextSensorStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif -bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->level = static_cast(value); +void SubscribeLogsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.level = static_cast(value.as_varint()); break; - case 2: - this->dump_config = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.dump_config = value.as_bool(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +SubscribeLogsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.level)); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.message_len_); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.message_ptr_, msg.message_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SubscribeLogsResponse::calculate_size() const { +SubscribeLogsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 2; - size += ProtoSize::calc_length_force(1, this->message_len_); + size += ProtoSize::calc_length_force(1, msg.message_len_); return size; } #ifdef USE_API_NOISE -bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->key = value.data(); - this->key_len = value.size(); +void NoiseEncryptionSetKeyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.key = value.data(); + msg.key_len = value.size(); break; - } - default: - return false; } - return true; } -uint8_t *NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *NoiseEncryptionSetKeyResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } -uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const { +uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->success); + size += ProtoSize::calc_bool(1, msg.success); return size; } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -uint8_t *HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *HomeassistantServiceMap::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->value); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); return pos; } -uint32_t HomeassistantServiceMap::calculate_size() const { +uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->key.size()); - size += ProtoSize::calc_length(1, this->value.size()); + size += ProtoSize::calc_length(1, msg.key.size()); + size += ProtoSize::calc_length(1, msg.value.size()); return size; } -uint8_t *HomeassistantActionRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *HomeassistantActionRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->service); - for (auto &it : this->data) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); + for (auto &it : msg.data) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } - for (auto &it : this->data_template) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + for (auto &it : msg.data_template) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - for (auto &it : this->variables) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + for (auto &it : msg.variables) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->is_event); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->call_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.call_id); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->wants_response); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.wants_response); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->response_template); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.response_template); #endif return pos; } -uint32_t HomeassistantActionRequest::calculate_size() const { +uint32_t HomeassistantActionRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->service.size()); - if (!this->data.empty()) { - for (const auto &it : this->data) { + size += ProtoSize::calc_length(1, msg.service.size()); + if (!msg.data.empty()) { + for (const auto &it : msg.data) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->data_template.empty()) { - for (const auto &it : this->data_template) { + if (!msg.data_template.empty()) { + for (const auto &it : msg.data_template) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->variables.empty()) { - for (const auto &it : this->variables) { + if (!msg.variables.empty()) { + for (const auto &it : msg.variables) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_bool(1, this->is_event); + size += ProtoSize::calc_bool(1, msg.is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - size += ProtoSize::calc_uint32(1, this->call_id); + size += ProtoSize::calc_uint32(1, msg.call_id); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - size += ProtoSize::calc_bool(1, this->wants_response); + size += ProtoSize::calc_bool(1, msg.wants_response); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - size += ProtoSize::calc_length(1, this->response_template.size()); + size += ProtoSize::calc_length(1, msg.response_template.size()); #endif return size; } #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->call_id = value; +void HomeassistantActionResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.call_id = value.as_varint(); break; - case 2: - this->success = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.success = value.as_bool(); break; - default: - return false; - } - return true; -} -bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.error_message = StringRef(value.data(), value.size()); break; - } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - case 4: { - this->response_data = value.data(); - this->response_data_len = value.size(); + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.response_data = value.data(); + msg.response_data_len = value.size(); break; - } #endif - default: - return false; } - return true; } #endif #ifdef USE_API_HOMEASSISTANT_STATES -uint8_t *SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SubscribeHomeAssistantStateResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->entity_id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->attribute); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->once); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.entity_id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.attribute); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.once); return pos; } -uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { +uint32_t SubscribeHomeAssistantStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->entity_id.size()); - size += ProtoSize::calc_length(1, this->attribute.size()); - size += ProtoSize::calc_bool(1, this->once); + size += ProtoSize::calc_length(1, msg.entity_id.size()); + size += ProtoSize::calc_length(1, msg.attribute.size()); + size += ProtoSize::calc_bool(1, msg.once); return size; } -bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); +void HomeAssistantStateResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.entity_id = StringRef(value.data(), value.size()); break; - } - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.state = StringRef(value.data(), value.size()); break; - } - case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.attribute = StringRef(value.data(), value.size()); break; - } - default: - return false; } - return true; } #endif -bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->time_seconds = decode_zigzag32(static_cast(value)); +void DSTRule::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.time_seconds = decode_zigzag32(static_cast(value.as_varint())); break; - case 2: - this->day = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.day = value.as_varint(); break; - case 3: - this->type = static_cast(value); + case proto_tag(3, WIRE_TYPE_VARINT): + msg.type = static_cast(value.as_varint()); break; - case 4: - this->month = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.month = value.as_varint(); break; - case 5: - this->week = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.week = value.as_varint(); break; - case 6: - this->day_of_week = value; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.day_of_week = value.as_varint(); break; - default: - return false; } - return true; } -bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->std_offset_seconds = decode_zigzag32(static_cast(value)); +void ParsedTimezone::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.std_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); break; - case 2: - this->dst_offset_seconds = decode_zigzag32(static_cast(value)); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.dst_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + value.decode_to_message(msg.dst_start); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + value.decode_to_message(msg.dst_end); break; - default: - return false; } - return true; } -bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: - value.decode_to_message(this->dst_start); +void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.epoch_seconds = value.as_fixed32(); break; - case 4: - value.decode_to_message(this->dst_end); + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + value.decode_to_message(msg.parsed_timezone); + msg.has_parsed_timezone = true; break; - default: - return false; } - return true; -} -bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: - value.decode_to_message(this->parsed_timezone); - this->has_parsed_timezone = true; - break; - default: - return false; - } - return true; -} -bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; - } - return true; } #ifdef USE_API_USER_DEFINED_ACTIONS -uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); #endif return pos; } -uint32_t ListEntitiesServicesArgument::calculate_size() const { +uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += this->type ? 2 : 0; + size += ProtoSize::calc_length(1, msg.name.size()); + size += msg.type ? 2 : 0; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - size += ProtoSize::calc_length(1, this->description.size()); + size += ProtoSize::calc_length(1, msg.description.size()); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - size += ProtoSize::calc_length(1, this->example.size()); + size += ProtoSize::calc_length(1, msg.example.size()); #endif return size; } -uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - for (auto &it : this->args) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + for (auto &it : msg.args) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); #endif return pos; } -uint32_t ListEntitiesServicesResponse::calculate_size() const { +uint32_t ListEntitiesServicesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); + size += ProtoSize::calc_length(1, msg.name.size()); size += 5; - if (!this->args.empty()) { - for (const auto &it : this->args) { + if (!msg.args.empty()) { + for (const auto &it : msg.args) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += this->supports_response ? 2 : 0; + size += msg.supports_response ? 2 : 0; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - size += ProtoSize::calc_length(1, this->description.size()); + size += ProtoSize::calc_length(1, msg.description.size()); #endif return size; } -bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->bool_ = value != 0; +void ExecuteServiceArgument::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.bool_ = value.as_bool(); break; - case 2: - this->legacy_int = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.legacy_int = static_cast(value.as_varint()); break; - case 5: - this->int_ = decode_zigzag32(static_cast(value)); + case proto_tag(3, WIRE_TYPE_FIXED32): + msg.float_ = value.as_float(); break; - case 6: - this->bool_array.push_back(value != 0); + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.string_ = StringRef(value.data(), value.size()); break; - case 7: - this->int_array.push_back(decode_zigzag32(static_cast(value))); + case proto_tag(5, WIRE_TYPE_VARINT): + msg.int_ = decode_zigzag32(static_cast(value.as_varint())); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.bool_array.push_back(value.as_bool()); + break; + case proto_tag(7, WIRE_TYPE_VARINT): + msg.int_array.push_back(decode_zigzag32(static_cast(value.as_varint()))); + break; + case proto_tag(8, WIRE_TYPE_FIXED32): + msg.float_array.push_back(value.as_float()); + break; + case proto_tag(9, WIRE_TYPE_LENGTH_DELIMITED): + msg.string_array.push_back(value.as_string()); break; - default: - return false; } - return true; -} -bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: - this->string_array.push_back(value.as_string()); - break; - default: - return false; - } - return true; -} -bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 3: - this->float_ = value.as_float(); - break; - case 8: - this->float_array.push_back(value.as_float()); - break; - default: - return false; - } - return true; } void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { uint32_t count_bool_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 6); @@ -1381,1114 +1372,1083 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->float_array.init(count_float_array); uint32_t count_string_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 9); this->string_array.init(count_string_array); - ProtoDecodableMessage::decode(buffer, length); + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } -bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void ExecuteServiceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.args.emplace_back(); + value.decode_to_message(msg.args.back()); + break; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: - this->call_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.call_id = value.as_varint(); break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: - this->return_response = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.return_response = value.as_bool(); break; #endif - default: - return false; } - return true; -} -bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: - this->args.emplace_back(); - value.decode_to_message(this->args.back()); - break; - default: - return false; - } - return true; -} -bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { uint32_t count_args = ProtoDecodableMessage::count_repeated_field(buffer, length, 2); this->args.init(count_args); - ProtoDecodableMessage::decode(buffer, length); + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES -uint8_t *ExecuteServiceResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ExecuteServiceResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->call_id); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error_message); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.call_id); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error_message); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, this->response_data, this->response_data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.response_data, msg.response_data_len); #endif return pos; } -uint32_t ExecuteServiceResponse::calculate_size() const { +uint32_t ExecuteServiceResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->call_id); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_length(1, this->error_message.size()); + size += ProtoSize::calc_uint32(1, msg.call_id); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_length(1, msg.error_message.size()); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - size += ProtoSize::calc_length(1, this->response_data_len); + size += ProtoSize::calc_length(1, msg.response_data_len); #endif return size; } #endif #ifdef USE_CAMERA -uint8_t *ListEntitiesCameraResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesCameraResponse::calculate_size() const { +uint32_t ListEntitiesCameraResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *CameraImageResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *CameraImageResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->done); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.done); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t CameraImageResponse::calculate_size() const { +uint32_t CameraImageResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->data_len_); - size += ProtoSize::calc_bool(1, this->done); + size += ProtoSize::calc_length(1, msg.data_len_); + size += ProtoSize::calc_bool(1, msg.done); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->single = value != 0; +void CameraImageRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.single = value.as_bool(); break; - case 2: - this->stream = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.stream = value.as_bool(); break; - default: - return false; } - return true; } #endif #ifdef USE_CLIMATE -uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_current_temperature); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_two_point_target_temperature); - for (const auto &it : *this->supported_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it), true); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_current_temperature); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_two_point_target_temperature); + for (const auto &it : *msg.supported_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it)); } - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->visual_min_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->visual_max_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->visual_target_temperature_step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_action); - for (const auto &it : *this->supported_fan_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it), true); + if (uint32_t raw = float_to_raw(msg.visual_min_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - for (const auto &it : *this->supported_swing_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it), true); + if (uint32_t raw = float_to_raw(msg.visual_max_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); } - for (const char *it : *this->supported_custom_fan_modes) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it), true); + if (uint32_t raw = float_to_raw(msg.visual_target_temperature_step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - for (const auto &it : *this->supported_presets) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it), true); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supports_action); + for (const auto &it : *msg.supported_fan_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it)); } - for (const char *it : *this->supported_custom_presets) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it), true); + for (const auto &it : *msg.supported_swing_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, this->disabled_by_default); + for (const char *it : *msg.supported_custom_fan_modes) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it)); + } + for (const auto &it : *msg.supported_presets) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it)); + } + for (const char *it : *msg.supported_custom_presets) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it)); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(this->entity_category)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, this->visual_current_temperature_step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, this->supports_current_humidity); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, this->supports_target_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, this->visual_min_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, this->visual_max_humidity); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.visual_current_temperature_step); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, msg.supports_current_humidity); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.supports_target_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, msg.visual_min_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, msg.visual_max_humidity); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(msg.temperature_unit)); return pos; } -uint32_t ListEntitiesClimateResponse::calculate_size() const { +uint32_t ListEntitiesClimateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->supports_current_temperature); - size += ProtoSize::calc_bool(1, this->supports_two_point_target_temperature); - if (!this->supported_modes->empty()) { - size += this->supported_modes->size() * 2; + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.supports_current_temperature); + size += ProtoSize::calc_bool(1, msg.supports_two_point_target_temperature); + if (!msg.supported_modes->empty()) { + size += msg.supported_modes->size() * 2; } - size += ProtoSize::calc_float(1, this->visual_min_temperature); - size += ProtoSize::calc_float(1, this->visual_max_temperature); - size += ProtoSize::calc_float(1, this->visual_target_temperature_step); - size += ProtoSize::calc_bool(1, this->supports_action); - if (!this->supported_fan_modes->empty()) { - size += this->supported_fan_modes->size() * 2; + size += ProtoSize::calc_float(1, msg.visual_min_temperature); + size += ProtoSize::calc_float(1, msg.visual_max_temperature); + size += ProtoSize::calc_float(1, msg.visual_target_temperature_step); + size += ProtoSize::calc_bool(1, msg.supports_action); + if (!msg.supported_fan_modes->empty()) { + size += msg.supported_fan_modes->size() * 2; } - if (!this->supported_swing_modes->empty()) { - size += this->supported_swing_modes->size() * 2; + if (!msg.supported_swing_modes->empty()) { + size += msg.supported_swing_modes->size() * 2; } - if (!this->supported_custom_fan_modes->empty()) { - for (const char *it : *this->supported_custom_fan_modes) { + if (!msg.supported_custom_fan_modes->empty()) { + for (const char *it : *msg.supported_custom_fan_modes) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - if (!this->supported_presets->empty()) { - size += this->supported_presets->size() * 3; + if (!msg.supported_presets->empty()) { + size += msg.supported_presets->size() * 3; } - if (!this->supported_custom_presets->empty()) { - for (const char *it : *this->supported_custom_presets) { + if (!msg.supported_custom_presets->empty()) { + for (const char *it : *msg.supported_custom_presets) { size += ProtoSize::calc_length_force(2, strlen(it)); } } - size += ProtoSize::calc_bool(2, this->disabled_by_default); + size += ProtoSize::calc_bool(2, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 3 + this->icon.size() : 0; + size += !msg.icon.empty() ? 3 + msg.icon.size() : 0; #endif - size += this->entity_category ? 3 : 0; - size += ProtoSize::calc_float(2, this->visual_current_temperature_step); - size += ProtoSize::calc_bool(2, this->supports_current_humidity); - size += ProtoSize::calc_bool(2, this->supports_target_humidity); - size += ProtoSize::calc_float(2, this->visual_min_humidity); - size += ProtoSize::calc_float(2, this->visual_max_humidity); + size += msg.entity_category ? 3 : 0; + size += ProtoSize::calc_float(2, msg.visual_current_temperature_step); + size += ProtoSize::calc_bool(2, msg.supports_current_humidity); + size += ProtoSize::calc_bool(2, msg.supports_target_humidity); + size += ProtoSize::calc_float(2, msg.visual_min_humidity); + size += ProtoSize::calc_float(2, msg.visual_max_humidity); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif - size += ProtoSize::calc_uint32(2, this->feature_flags); - size += this->temperature_unit ? 3 : 0; + size += ProtoSize::calc_uint32(2, msg.feature_flags); + size += msg.temperature_unit ? 3 : 0; return size; } -uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ClimateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->current_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->target_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->target_temperature_low); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->target_temperature_high); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->action)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->fan_mode)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->swing_mode)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->custom_fan_mode); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->preset)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->custom_preset); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 14, this->current_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 15, this->target_humidity); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); + if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.action)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(msg.fan_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.swing_mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.custom_fan_mode); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.preset)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.custom_preset); + if (uint32_t raw = float_to_raw(msg.current_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 117, raw); + } + if (uint32_t raw = float_to_raw(msg.target_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 125, raw); + } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.missing_state); return pos; } -uint32_t ClimateStateResponse::calculate_size() const { +uint32_t ClimateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->mode ? 2 : 0; - size += ProtoSize::calc_float(1, this->current_temperature); - size += ProtoSize::calc_float(1, this->target_temperature); - size += ProtoSize::calc_float(1, this->target_temperature_low); - size += ProtoSize::calc_float(1, this->target_temperature_high); - size += this->action ? 2 : 0; - size += this->fan_mode ? 2 : 0; - size += this->swing_mode ? 2 : 0; - size += ProtoSize::calc_length(1, this->custom_fan_mode.size()); - size += this->preset ? 2 : 0; - size += ProtoSize::calc_length(1, this->custom_preset.size()); - size += ProtoSize::calc_float(1, this->current_humidity); - size += ProtoSize::calc_float(1, this->target_humidity); + size += msg.mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.current_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature_low); + size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += msg.action ? 2 : 0; + size += msg.fan_mode ? 2 : 0; + size += msg.swing_mode ? 2 : 0; + size += ProtoSize::calc_length(1, msg.custom_fan_mode.size()); + size += msg.preset ? 2 : 0; + size += ProtoSize::calc_length(1, msg.custom_preset.size()); + size += ProtoSize::calc_float(1, msg.current_humidity); + size += ProtoSize::calc_float(1, msg.target_humidity); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif + size += ProtoSize::calc_bool(2, msg.missing_state); return size; } -bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_mode = value != 0; +void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_mode = value.as_bool(); break; - case 4: - this->has_target_temperature = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); break; - case 6: - this->has_target_temperature_low = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_target_temperature = value.as_bool(); break; - case 8: - this->has_target_temperature_high = value != 0; + case proto_tag(5, WIRE_TYPE_FIXED32): + msg.target_temperature = value.as_float(); break; - case 12: - this->has_fan_mode = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_target_temperature_low = value.as_bool(); break; - case 13: - this->fan_mode = static_cast(value); + case proto_tag(7, WIRE_TYPE_FIXED32): + msg.target_temperature_low = value.as_float(); break; - case 14: - this->has_swing_mode = value != 0; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_target_temperature_high = value.as_bool(); break; - case 15: - this->swing_mode = static_cast(value); + case proto_tag(9, WIRE_TYPE_FIXED32): + msg.target_temperature_high = value.as_float(); break; - case 16: - this->has_custom_fan_mode = value != 0; + case proto_tag(12, WIRE_TYPE_VARINT): + msg.has_fan_mode = value.as_bool(); break; - case 18: - this->has_preset = value != 0; + case proto_tag(13, WIRE_TYPE_VARINT): + msg.fan_mode = static_cast(value.as_varint()); break; - case 19: - this->preset = static_cast(value); + case proto_tag(14, WIRE_TYPE_VARINT): + msg.has_swing_mode = value.as_bool(); break; - case 20: - this->has_custom_preset = value != 0; + case proto_tag(15, WIRE_TYPE_VARINT): + msg.swing_mode = static_cast(value.as_varint()); break; - case 22: - this->has_target_humidity = value != 0; + case proto_tag(16, WIRE_TYPE_VARINT): + msg.has_custom_fan_mode = value.as_bool(); + break; + case proto_tag(17, WIRE_TYPE_LENGTH_DELIMITED): + msg.custom_fan_mode = StringRef(value.data(), value.size()); + break; + case proto_tag(18, WIRE_TYPE_VARINT): + msg.has_preset = value.as_bool(); + break; + case proto_tag(19, WIRE_TYPE_VARINT): + msg.preset = static_cast(value.as_varint()); + break; + case proto_tag(20, WIRE_TYPE_VARINT): + msg.has_custom_preset = value.as_bool(); + break; + case proto_tag(21, WIRE_TYPE_LENGTH_DELIMITED): + msg.custom_preset = StringRef(value.data(), value.size()); + break; + case proto_tag(22, WIRE_TYPE_VARINT): + msg.has_target_humidity = value.as_bool(); + break; + case proto_tag(23, WIRE_TYPE_FIXED32): + msg.target_humidity = value.as_float(); break; #ifdef USE_DEVICES - case 24: - this->device_id = value; + case proto_tag(24, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 9: - this->target_temperature_high = value.as_float(); - break; - case 23: - this->target_humidity = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_WATER_HEATER -uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesWaterHeaterResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->target_temperature_step); - for (const auto &it : *this->supported_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); + if (uint32_t raw = float_to_raw(msg.min_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); + if (uint32_t raw = float_to_raw(msg.max_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + for (const auto &it : *msg.supported_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it)); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supported_features); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(msg.temperature_unit)); return pos; } -uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { +uint32_t ListEntitiesWaterHeaterResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_float(1, this->min_temperature); - size += ProtoSize::calc_float(1, this->max_temperature); - size += ProtoSize::calc_float(1, this->target_temperature_step); - if (!this->supported_modes->empty()) { - size += this->supported_modes->size() * 2; + size += ProtoSize::calc_float(1, msg.min_temperature); + size += ProtoSize::calc_float(1, msg.max_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature_step); + if (!msg.supported_modes->empty()) { + size += msg.supported_modes->size() * 2; } - size += ProtoSize::calc_uint32(1, this->supported_features); - size += this->temperature_unit ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.supported_features); + size += msg.temperature_unit ? 2 : 0; return size; } -uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->current_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->target_temperature); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->mode)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.mode)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->state); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->target_temperature_low); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->target_temperature_high); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.state); + if (uint32_t raw = float_to_raw(msg.target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.missing_state); return pos; } -uint32_t WaterHeaterStateResponse::calculate_size() const { +uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->current_temperature); - size += ProtoSize::calc_float(1, this->target_temperature); - size += this->mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.current_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature); + size += msg.mode ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->state); - size += ProtoSize::calc_float(1, this->target_temperature_low); - size += ProtoSize::calc_float(1, this->target_temperature_high); + size += ProtoSize::calc_uint32(1, msg.state); + size += ProtoSize::calc_float(1, msg.target_temperature_low); + size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } -bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_fields = value; +void WaterHeaterCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_fields = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); + break; + case proto_tag(4, WIRE_TYPE_FIXED32): + msg.target_temperature = value.as_float(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - case 6: - this->state = value; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.state = value.as_varint(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): + msg.target_temperature_low = value.as_float(); + break; + case proto_tag(8, WIRE_TYPE_FIXED32): + msg.target_temperature_high = value.as_float(); break; - default: - return false; } - return true; -} -bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 4: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 8: - this->target_temperature_high = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_NUMBER -uint8_t *ListEntitiesNumberResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->min_value); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->max_value); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->unit_of_measurement); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->mode)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_class); + if (uint32_t raw = float_to_raw(msg.min_value); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + if (uint32_t raw = float_to_raw(msg.max_value); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(msg.step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.unit_of_measurement); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t ListEntitiesNumberResponse::calculate_size() const { +uint32_t ListEntitiesNumberResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_float(1, this->min_value); - size += ProtoSize::calc_float(1, this->max_value); - size += ProtoSize::calc_float(1, this->step); - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; - size += this->mode ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_float(1, msg.min_value); + size += ProtoSize::calc_float(1, msg.max_value); + size += ProtoSize::calc_float(1, msg.step); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.unit_of_measurement.empty() ? 2 + msg.unit_of_measurement.size() : 0; + size += msg.mode ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *NumberStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *NumberStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t NumberStateResponse::calculate_size() const { +uint32_t NumberStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_float(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void NumberCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_FIXED32): + msg.state = value.as_float(); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->state = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_SELECT -uint8_t *ListEntitiesSelectResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - for (const char *it : *this->options) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it), true); + for (const char *it : *msg.options) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSelectResponse::calculate_size() const { +uint32_t ListEntitiesSelectResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - if (!this->options->empty()) { - for (const char *it : *this->options) { + if (!msg.options->empty()) { + for (const char *it : *msg.options) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SelectStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SelectStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t SelectStateResponse::calculate_size() const { +uint32_t SelectStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void SelectCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.state = StringRef(value.data(), value.size()); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_SIREN -uint8_t *ListEntitiesSirenResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - for (const char *it : *this->tones) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it), true); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + for (const char *it : *msg.tones) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_duration); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_volume); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supports_duration); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.supports_volume); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSirenResponse::calculate_size() const { +uint32_t ListEntitiesSirenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - if (!this->tones->empty()) { - for (const char *it : *this->tones) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + if (!msg.tones->empty()) { + for (const char *it : *msg.tones) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->supports_duration); - size += ProtoSize::calc_bool(1, this->supports_volume); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.supports_duration); + size += ProtoSize::calc_bool(1, msg.supports_volume); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SirenStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SirenStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t SirenStateResponse::calculate_size() const { +uint32_t SirenStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); + size += ProtoSize::calc_bool(1, msg.state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; +void SirenCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->state = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_state = value.as_bool(); break; - case 4: - this->has_tone = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.state = value.as_bool(); break; - case 6: - this->has_duration = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_tone = value.as_bool(); break; - case 7: - this->duration = value; + case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): + msg.tone = StringRef(value.data(), value.size()); break; - case 8: - this->has_volume = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_duration = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_VARINT): + msg.duration = value.as_varint(); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_volume = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_FIXED32): + msg.volume = value.as_float(); break; #ifdef USE_DEVICES - case 10: - this->device_id = value; + case proto_tag(10, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 9: - this->volume = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_LOCK -uint8_t *ListEntitiesLockResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_open); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->code_format); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.supports_open); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.requires_code); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.code_format); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesLockResponse::calculate_size() const { +uint32_t ListEntitiesLockResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_open); - size += ProtoSize::calc_bool(1, this->requires_code); - size += ProtoSize::calc_length(1, this->code_format.size()); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_open); + size += ProtoSize::calc_bool(1, msg.requires_code); + size += ProtoSize::calc_length(1, msg.code_format.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *LockStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *LockStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t LockStateResponse::calculate_size() const { +uint32_t LockStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; + size += msg.state ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); +void LockCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->has_code = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.has_code = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.code = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_BUTTON -uint8_t *ListEntitiesButtonResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesButtonResponse::calculate_size() const { +uint32_t ListEntitiesButtonResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void ButtonCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; #ifdef USE_DEVICES - case 2: - this->device_id = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_MEDIA_PLAYER -uint8_t *MediaPlayerSupportedFormat::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->format); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->sample_rate); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->num_channels); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->purpose)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->sample_bytes); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.format); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.sample_rate); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.purpose)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.sample_bytes); return pos; } -uint32_t MediaPlayerSupportedFormat::calculate_size() const { +uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->format.size()); - size += ProtoSize::calc_uint32(1, this->sample_rate); - size += ProtoSize::calc_uint32(1, this->num_channels); - size += this->purpose ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->sample_bytes); + size += ProtoSize::calc_length(1, msg.format.size()); + size += ProtoSize::calc_uint32(1, msg.sample_rate); + size += ProtoSize::calc_uint32(1, msg.num_channels); + size += msg.purpose ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.sample_bytes); return size; } -uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - for (auto &it : this->supported_formats) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + for (auto &it : msg.supported_formats) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.feature_flags); return pos; } -uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { +uint32_t ListEntitiesMediaPlayerResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - if (!this->supported_formats.empty()) { - for (const auto &it : this->supported_formats) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + if (!msg.supported_formats.empty()) { + for (const auto &it : msg.supported_formats) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } -uint8_t *MediaPlayerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *MediaPlayerStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->muted); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); + if (uint32_t raw = float_to_raw(msg.volume); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.muted); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_id); #endif return pos; } -uint32_t MediaPlayerStateResponse::calculate_size() const { +uint32_t MediaPlayerStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; - size += ProtoSize::calc_float(1, this->volume); - size += ProtoSize::calc_bool(1, this->muted); + size += msg.state ? 2 : 0; + size += ProtoSize::calc_float(1, msg.volume); + size += ProtoSize::calc_bool(1, msg.muted); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_command = value != 0; +void MediaPlayerCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->command = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_command = value.as_bool(); break; - case 4: - this->has_volume = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); break; - case 6: - this->has_media_url = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_volume = value.as_bool(); break; - case 8: - this->has_announcement = value != 0; + case proto_tag(5, WIRE_TYPE_FIXED32): + msg.volume = value.as_float(); break; - case 9: - this->announcement = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_media_url = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): + msg.media_url = StringRef(value.data(), value.size()); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_announcement = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_VARINT): + msg.announcement = value.as_bool(); break; #ifdef USE_DEVICES - case 10: - this->device_id = value; + case proto_tag(10, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->volume = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_BLUETOOTH_PROXY -bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->flags = value; +void SubscribeBluetoothLEAdvertisementsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.flags = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +BluetoothLERawAdvertisementsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - for (uint16_t i = 0; i < this->advertisements_len; i++) { - auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + for (uint16_t i = 0; i < msg.advertisements_len; i++) { + auto &sub_msg = msg.advertisements[i]; + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + pos = ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + pos = ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + pos = ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); *len_pos = static_cast(pos - len_pos - 1); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -BluetoothLERawAdvertisementsResponse::calculate_size() const { +BluetoothLERawAdvertisementsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - for (uint16_t i = 0; i < this->advertisements_len; i++) { - auto &sub_msg = this->advertisements[i]; + for (uint16_t i = 0; i < msg.advertisements_len; i++) { + auto &sub_msg = msg.advertisements[i]; size += 2; size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address); size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); @@ -2499,1584 +2459,1525 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothDeviceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->request_type = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.request_type = static_cast(value.as_varint()); break; - case 3: - this->has_address_type = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.has_address_type = value.as_bool(); break; - case 4: - this->address_type = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.address_type = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->connected); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mtu); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.connected); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.mtu); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.error); return pos; } -uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { +uint32_t BluetoothDeviceConnectionResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->connected); - size += ProtoSize::calc_uint32(1, this->mtu); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.connected); + size += ProtoSize::calc_uint32(1, msg.mtu); + size += ProtoSize::calc_int32(1, msg.error); return size; } -bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTGetServicesRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothGATTDescriptor::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.short_uuid); return pos; } -uint32_t BluetoothGATTDescriptor::calculate_size() const { +uint32_t BluetoothGATTDescriptor::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTCharacteristic::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->properties); - for (auto &it : this->descriptors) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.properties); + for (auto &it : msg.descriptors) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.short_uuid); return pos; } -uint32_t BluetoothGATTCharacteristic::calculate_size() const { +uint32_t BluetoothGATTCharacteristic::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_uint32(1, this->properties); - if (!this->descriptors.empty()) { - for (const auto &it : this->descriptors) { + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_uint32(1, msg.properties); + if (!msg.descriptors.empty()) { + for (const auto &it : msg.descriptors) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTService::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTService::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - for (auto &it : this->characteristics) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + for (auto &it : msg.characteristics) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.short_uuid); return pos; } -uint32_t BluetoothGATTService::calculate_size() const { +uint32_t BluetoothGATTService::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - if (!this->characteristics.empty()) { - for (const auto &it : this->characteristics) { + size += ProtoSize::calc_uint32(1, msg.handle); + if (!msg.characteristics.empty()) { + for (const auto &it : msg.characteristics) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTGetServicesResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - for (auto &it : this->services) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + for (auto &it : msg.services) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } return pos; } -uint32_t BluetoothGATTGetServicesResponse::calculate_size() const { +uint32_t BluetoothGATTGetServicesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - if (!this->services.empty()) { - for (const auto &it : this->services) { + size += ProtoSize::calc_uint64(1, msg.address); + if (!msg.services.empty()) { + for (const auto &it : msg.services) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } return size; } -uint8_t *BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTGetServicesDoneResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); return pos; } -uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { +uint32_t BluetoothGATTGetServicesDoneResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); + size += ProtoSize::calc_uint64(1, msg.address); return size; } -bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTReadRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothGATTReadResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); return pos; } -uint32_t BluetoothGATTReadResponse::calculate_size() const { +uint32_t BluetoothGATTReadResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTWriteRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); break; - case 3: - this->response = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.response = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->data = value.data(); - this->data_len = value.size(); +void BluetoothGATTReadDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); break; - } - default: - return false; } - return true; } -bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTWriteDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTNotifyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.enable = value.as_bool(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; - } - return true; -} -bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->enable = value != 0; - break; - default: - return false; - } - return true; -} -uint8_t *BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); return pos; } -uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { +uint32_t BluetoothGATTNotifyDataResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -uint8_t *BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothConnectionsFreeResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->free); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->limit); - for (const auto &it : this->allocated) { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.free); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.limit); + for (const auto &it : msg.allocated) { if (it != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } } return pos; } -uint32_t BluetoothConnectionsFreeResponse::calculate_size() const { +uint32_t BluetoothConnectionsFreeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->free); - size += ProtoSize::calc_uint32(1, this->limit); - for (const auto &it : this->allocated) { + size += ProtoSize::calc_uint32(1, msg.free); + size += ProtoSize::calc_uint32(1, msg.limit); + for (const auto &it : msg.allocated) { if (it != 0) { size += ProtoSize::calc_uint64_force(1, it); } } return size; } -uint8_t *BluetoothGATTErrorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTErrorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothGATTErrorResponse::calculate_size() const { +uint32_t BluetoothGATTErrorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothGATTWriteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTWriteResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; } -uint32_t BluetoothGATTWriteResponse::calculate_size() const { +uint32_t BluetoothGATTWriteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTNotifyResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; } -uint32_t BluetoothGATTNotifyResponse::calculate_size() const { +uint32_t BluetoothGATTNotifyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothDevicePairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDevicePairingResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->paired); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.paired); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDevicePairingResponse::calculate_size() const { +uint32_t BluetoothDevicePairingResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->paired); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.paired); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDeviceUnpairingResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { +uint32_t BluetoothDeviceUnpairingResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDeviceClearCacheResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { +uint32_t BluetoothDeviceClearCacheResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_int32(1, msg.error); return size; } #endif #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothScannerStateResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->configured_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.state)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.configured_mode)); return pos; } -uint32_t BluetoothScannerStateResponse::calculate_size() const { +uint32_t BluetoothScannerStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->state ? 2 : 0; - size += this->mode ? 2 : 0; - size += this->configured_mode ? 2 : 0; + size += msg.state ? 2 : 0; + size += msg.mode ? 2 : 0; + size += msg.configured_mode ? 2 : 0; return size; } -bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->mode = static_cast(value); +void BluetoothScannerSetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); break; - default: - return false; } - return true; } #endif #ifdef USE_VOICE_ASSISTANT -bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->subscribe = value != 0; +void SubscribeVoiceAssistantRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.subscribe = value.as_bool(); break; - case 2: - this->flags = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.flags = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *VoiceAssistantAudioSettings::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->noise_suppression_level); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->auto_gain); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume_multiplier); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.noise_suppression_level); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.auto_gain); + if (uint32_t raw = float_to_raw(msg.volume_multiplier); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } return pos; } -uint32_t VoiceAssistantAudioSettings::calculate_size() const { +uint32_t VoiceAssistantAudioSettings::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->noise_suppression_level); - size += ProtoSize::calc_uint32(1, this->auto_gain); - size += ProtoSize::calc_float(1, this->volume_multiplier); + size += ProtoSize::calc_uint32(1, msg.noise_suppression_level); + size += ProtoSize::calc_uint32(1, msg.auto_gain); + size += ProtoSize::calc_float(1, msg.volume_multiplier); return size; } -uint8_t *VoiceAssistantRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->start); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->conversation_id); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->flags); - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, this->audio_settings); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->wake_word_phrase); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.start); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.conversation_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, msg.audio_settings); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wake_word_phrase); return pos; } -uint32_t VoiceAssistantRequest::calculate_size() const { +uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->start); - size += ProtoSize::calc_length(1, this->conversation_id.size()); - size += ProtoSize::calc_uint32(1, this->flags); - size += ProtoSize::calc_message(1, this->audio_settings.calculate_size()); - size += ProtoSize::calc_length(1, this->wake_word_phrase.size()); + size += ProtoSize::calc_bool(1, msg.start); + size += ProtoSize::calc_length(1, msg.conversation_id.size()); + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_message(1, msg.audio_settings.calculate_size()); + size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); return size; } -bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->port = value; +void VoiceAssistantResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.port = value.as_varint(); break; - case 2: - this->error = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.error = value.as_bool(); break; - default: - return false; } - return true; } -bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantEventData::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.name = StringRef(value.data(), value.size()); break; - } - case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.value = StringRef(value.data(), value.size()); break; - } - default: - return false; } - return true; } -bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->event_type = static_cast(value); +void VoiceAssistantEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.event_type = static_cast(value.as_varint()); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.data.emplace_back(); + value.decode_to_message(msg.data.back()); break; - default: - return false; } - return true; } -bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: - this->data.emplace_back(); - value.decode_to_message(this->data.back()); +void VoiceAssistantAudio::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.end = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.data2 = value.data(); + msg.data2_len = value.size(); break; - default: - return false; } - return true; } -bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->end = value != 0; - break; - default: - return false; - } - return true; -} -bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - case 3: { - this->data2 = value.data(); - this->data2_len = value.size(); - break; - } - default: - return false; - } - return true; -} -uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantAudio::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.end); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data2, msg.data2_len); return pos; } -uint32_t VoiceAssistantAudio::calculate_size() const { +uint32_t VoiceAssistantAudio::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); - size += ProtoSize::calc_bool(1, this->end); - size += ProtoSize::calc_length(1, this->data2_len); + size += ProtoSize::calc_length(1, msg.data_len); + size += ProtoSize::calc_bool(1, msg.end); + size += ProtoSize::calc_length(1, msg.data2_len); return size; } -bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->event_type = static_cast(value); +void VoiceAssistantTimerEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.event_type = static_cast(value.as_varint()); break; - case 4: - this->total_seconds = value; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.timer_id = StringRef(value.data(), value.size()); break; - case 5: - this->seconds_left = value; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.name = StringRef(value.data(), value.size()); break; - case 6: - this->is_active = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.total_seconds = value.as_varint(); + break; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.seconds_left = value.as_varint(); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.is_active = value.as_bool(); break; - default: - return false; } - return true; } -bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantAnnounceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.media_id = StringRef(value.data(), value.size()); break; - } - case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.text = StringRef(value.data(), value.size()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.preannounce_media_id = StringRef(value.data(), value.size()); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.start_conversation = value.as_bool(); break; - } - default: - return false; } - return true; } -bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 4: - this->start_conversation = value != 0; - break; - default: - return false; - } - return true; -} -bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -uint8_t *VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } -uint32_t VoiceAssistantAnnounceFinished::calculate_size() const { +uint32_t VoiceAssistantAnnounceFinished::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->success); + size += ProtoSize::calc_bool(1, msg.success); return size; } -uint8_t *VoiceAssistantWakeWord::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantWakeWord::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->wake_word); - for (auto &it : this->trained_languages) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.wake_word); + for (auto &it : msg.trained_languages) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } -uint32_t VoiceAssistantWakeWord::calculate_size() const { +uint32_t VoiceAssistantWakeWord::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->id.size()); - size += ProtoSize::calc_length(1, this->wake_word.size()); - if (!this->trained_languages.empty()) { - for (const auto &it : this->trained_languages) { + size += ProtoSize::calc_length(1, msg.id.size()); + size += ProtoSize::calc_length(1, msg.wake_word.size()); + if (!msg.trained_languages.empty()) { + for (const auto &it : msg.trained_languages) { size += ProtoSize::calc_length_force(1, it.size()); } } return size; } -bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 5: - this->model_size = value; +void VoiceAssistantExternalWakeWord::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.id = StringRef(value.data(), value.size()); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.wake_word = StringRef(value.data(), value.size()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.trained_languages.push_back(value.as_string()); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.model_type = StringRef(value.data(), value.size()); + break; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.model_size = value.as_varint(); + break; + case proto_tag(6, WIRE_TYPE_LENGTH_DELIMITED): + msg.model_hash = StringRef(value.data(), value.size()); + break; + case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): + msg.url = StringRef(value.data(), value.size()); break; - default: - return false; } - return true; } -bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantConfigurationRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.external_wake_words.emplace_back(); + value.decode_to_message(msg.external_wake_words.back()); break; - } - case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - this->trained_languages.push_back(value.as_string()); - break; - case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; } - return true; } -bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: - this->external_wake_words.emplace_back(); - value.decode_to_message(this->external_wake_words.back()); - break; - default: - return false; - } - return true; -} -uint8_t *VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - for (auto &it : this->available_wake_words) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); + for (auto &it : msg.available_wake_words) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); } - for (const auto &it : *this->active_wake_words) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, it, true); + for (const auto &it : *msg.active_wake_words) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->max_active_wake_words); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.max_active_wake_words); return pos; } -uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { +uint32_t VoiceAssistantConfigurationResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (!this->available_wake_words.empty()) { - for (const auto &it : this->available_wake_words) { + if (!msg.available_wake_words.empty()) { + for (const auto &it : msg.available_wake_words) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->active_wake_words->empty()) { - for (const auto &it : *this->active_wake_words) { + if (!msg.active_wake_words->empty()) { + for (const auto &it : *msg.active_wake_words) { size += ProtoSize::calc_length_force(1, it.size()); } } - size += ProtoSize::calc_uint32(1, this->max_active_wake_words); + size += ProtoSize::calc_uint32(1, msg.max_active_wake_words); return size; } -bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: - this->active_wake_words.push_back(value.as_string()); +void VoiceAssistantSetConfiguration::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.active_wake_words.push_back(value.as_string()); break; - default: - return false; } - return true; } #endif #ifdef USE_ALARM_CONTROL_PANEL -uint8_t *ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesAlarmControlPanelResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_features); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->requires_code); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code_to_arm); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supported_features); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.requires_code); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.requires_code_to_arm); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t ListEntitiesAlarmControlPanelResponse::calculate_size() const { +uint32_t ListEntitiesAlarmControlPanelResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->supported_features); - size += ProtoSize::calc_bool(1, this->requires_code); - size += ProtoSize::calc_bool(1, this->requires_code_to_arm); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.supported_features); + size += ProtoSize::calc_bool(1, msg.requires_code); + size += ProtoSize::calc_bool(1, msg.requires_code_to_arm); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *AlarmControlPanelStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *AlarmControlPanelStateResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t AlarmControlPanelStateResponse::calculate_size() const { +uint32_t AlarmControlPanelStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; + size += msg.state ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); +void AlarmControlPanelCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.code = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES - case 4: - this->device_id = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_TEXT -uint8_t *ListEntitiesTextResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_length); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_length); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->pattern); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->mode)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.min_length); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.max_length); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.pattern); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.mode)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTextResponse::calculate_size() const { +uint32_t ListEntitiesTextResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->min_length); - size += ProtoSize::calc_uint32(1, this->max_length); - size += ProtoSize::calc_length(1, this->pattern.size()); - size += this->mode ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.min_length); + size += ProtoSize::calc_uint32(1, msg.max_length); + size += ProtoSize::calc_length(1, msg.pattern.size()); + size += msg.mode ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TextStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *TextStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t TextStateResponse::calculate_size() const { +uint32_t TextStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void TextCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.state = StringRef(value.data(), value.size()); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_DATETIME_DATE -uint8_t *ListEntitiesDateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesDateResponse::calculate_size() const { +uint32_t ListEntitiesDateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *DateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->year); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->month); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->day); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.year); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.month); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.day); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t DateStateResponse::calculate_size() const { +uint32_t DateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_uint32(1, this->year); - size += ProtoSize::calc_uint32(1, this->month); - size += ProtoSize::calc_uint32(1, this->day); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_uint32(1, msg.year); + size += ProtoSize::calc_uint32(1, msg.month); + size += ProtoSize::calc_uint32(1, msg.day); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->year = value; +void DateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->month = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.year = value.as_varint(); break; - case 4: - this->day = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.month = value.as_varint(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.day = value.as_varint(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_DATETIME_TIME -uint8_t *ListEntitiesTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTimeResponse::calculate_size() const { +uint32_t ListEntitiesTimeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *TimeStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->hour); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->minute); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->second); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.hour); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.minute); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.second); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t TimeStateResponse::calculate_size() const { +uint32_t TimeStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_uint32(1, this->hour); - size += ProtoSize::calc_uint32(1, this->minute); - size += ProtoSize::calc_uint32(1, this->second); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_uint32(1, msg.hour); + size += ProtoSize::calc_uint32(1, msg.minute); + size += ProtoSize::calc_uint32(1, msg.second); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->hour = value; +void TimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->minute = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.hour = value.as_varint(); break; - case 4: - this->second = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.minute = value.as_varint(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.second = value.as_varint(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_EVENT -uint8_t *ListEntitiesEventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - for (const char *it : *this->event_types) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it), true); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + for (const char *it : *msg.event_types) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it)); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesEventResponse::calculate_size() const { +uint32_t ListEntitiesEventResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - if (!this->event_types->empty()) { - for (const char *it : *this->event_types) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + if (!msg.event_types->empty()) { + for (const char *it : *msg.event_types) { size += ProtoSize::calc_length_force(1, strlen(it)); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *EventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *EventResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->event_type); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.event_type); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t EventResponse::calculate_size() const { +uint32_t EventResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->event_type.size()); + size += ProtoSize::calc_length(1, msg.event_type.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_VALVE -uint8_t *ListEntitiesValveResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->supports_position); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supports_stop); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supports_stop); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesValveResponse::calculate_size() const { +uint32_t ListEntitiesValveResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_position); - size += ProtoSize::calc_bool(1, this->supports_stop); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_position); + size += ProtoSize::calc_bool(1, msg.supports_stop); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *ValveStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ValveStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->position); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->current_operation)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.current_operation)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t ValveStateResponse::calculate_size() const { +uint32_t ValveStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->position); - size += this->current_operation ? 2 : 0; + size += ProtoSize::calc_float(1, msg.position); + size += msg.current_operation ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_position = value != 0; +void ValveCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 4: - this->stop = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_position = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_FIXED32): + msg.position = value.as_float(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.stop = value.as_bool(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 3: - this->position = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_DATETIME_DATETIME -uint8_t *ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesDateTimeResponse::calculate_size() const { +uint32_t ListEntitiesDateTimeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *DateTimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DateTimeStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->epoch_seconds); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + if (uint32_t raw = msg.epoch_seconds; raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t DateTimeStateResponse::calculate_size() const { +uint32_t DateTimeStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_fixed32(1, this->epoch_seconds); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_fixed32(1, msg.epoch_seconds); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void DateTimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_FIXED32): + msg.epoch_seconds = value.as_fixed32(); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_UPDATE -uint8_t *ListEntitiesUpdateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesUpdateResponse::calculate_size() const { +uint32_t ListEntitiesUpdateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *UpdateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *UpdateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->in_progress); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->has_progress); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->progress); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->current_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->latest_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->title); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->release_summary); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->release_url); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.in_progress); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.has_progress); + if (uint32_t raw = float_to_raw(msg.progress); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.current_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.latest_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.title); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.release_summary); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.release_url); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t UpdateStateResponse::calculate_size() const { +uint32_t UpdateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_bool(1, this->in_progress); - size += ProtoSize::calc_bool(1, this->has_progress); - size += ProtoSize::calc_float(1, this->progress); - size += ProtoSize::calc_length(1, this->current_version.size()); - size += ProtoSize::calc_length(1, this->latest_version.size()); - size += ProtoSize::calc_length(1, this->title.size()); - size += ProtoSize::calc_length(1, this->release_summary.size()); - size += ProtoSize::calc_length(1, this->release_url.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_bool(1, msg.in_progress); + size += ProtoSize::calc_bool(1, msg.has_progress); + size += ProtoSize::calc_float(1, msg.progress); + size += ProtoSize::calc_length(1, msg.current_version.size()); + size += ProtoSize::calc_length(1, msg.latest_version.size()); + size += ProtoSize::calc_length(1, msg.title.size()); + size += ProtoSize::calc_length(1, msg.release_summary.size()); + size += ProtoSize::calc_length(1, msg.release_url.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); +void UpdateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_ZWAVE_PROXY -bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->data = value.data(); - this->data_len = value.size(); +void ZWaveProxyFrame::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - } - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +ZWaveProxyFrame::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -ZWaveProxyFrame::calculate_size() const { +ZWaveProxyFrame::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); + size += ProtoSize::calc_length(1, msg.data_len); return size; } -bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->type = static_cast(value); +void ZWaveProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.type = static_cast(value.as_varint()); break; - default: - return false; - } - return true; -} -bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->data = value.data(); - this->data_len = value.size(); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - } - default: - return false; } - return true; } -uint8_t *ZWaveProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ZWaveProxyRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data, msg.data_len); return pos; } -uint32_t ZWaveProxyRequest::calculate_size() const { +uint32_t ZWaveProxyRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->type ? 2 : 0; - size += ProtoSize::calc_length(1, this->data_len); + size += msg.type ? 2 : 0; + size += ProtoSize::calc_length(1, msg.data_len); return size; } -uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ZWaveProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.status)); return pos; } -uint32_t ZWaveProxyRequestResponse::calculate_size() const { +uint32_t ZWaveProxyRequestResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->type ? 2 : 0; - size += this->status ? 2 : 0; + size += msg.type ? 2 : 0; + size += msg.status ? 2 : 0; return size; } #endif #ifdef USE_INFRARED -uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->receiver_frequency); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.receiver_frequency); return pos; } -uint32_t ListEntitiesInfraredResponse::calculate_size() const { +uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->capabilities); - size += ProtoSize::calc_uint32(1, this->receiver_frequency); + size += ProtoSize::calc_uint32(1, msg.capabilities); + size += ProtoSize::calc_uint32(1, msg.receiver_frequency); return size; } #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { #ifdef USE_DEVICES - case 1: - this->device_id = value; + case proto_tag(1, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - case 3: - this->carrier_frequency = value; + case proto_tag(2, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 4: - this->repeat_count = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.carrier_frequency = value.as_varint(); break; - case 6: - this->modulation = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.repeat_count = value.as_varint(); + break; + case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): + msg.timings_data_ = value.data(); + msg.timings_length_ = value.size(); + msg.timings_count_ = count_packed_varints(value.data(), value.size()); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.modulation = value.as_varint(); break; - default: - return false; } - return true; -} -bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: - return false; - } - return true; -} -bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 2: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +InfraredRFReceiveEvent::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); #endif - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - for (const auto &it : *this->timings) { - ProtoEncode::encode_sint32(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + for (const auto &it : *msg.timings) { + pos = ProtoEncode::encode_sint32_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -InfraredRFReceiveEvent::calculate_size() const { +InfraredRFReceiveEvent::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif size += 5; - if (!this->timings->empty()) { - for (const auto &it : *this->timings) { + if (!msg.timings->empty()) { + for (const auto &it : *msg.timings) { size += ProtoSize::calc_sint32_force(1, it); } } @@ -4084,211 +3985,353 @@ InfraredRFReceiveEvent::calculate_size() const { } #endif #ifdef USE_RADIO_FREQUENCY -uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.frequency_min); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.frequency_max); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supported_modulations); return pos; } -uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { +uint32_t ListEntitiesRadioFrequencyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->capabilities); - size += ProtoSize::calc_uint32(1, this->frequency_min); - size += ProtoSize::calc_uint32(1, this->frequency_max); - size += ProtoSize::calc_uint32(1, this->supported_modulations); + size += ProtoSize::calc_uint32(1, msg.capabilities); + size += ProtoSize::calc_uint32(1, msg.frequency_min); + size += ProtoSize::calc_uint32(1, msg.frequency_max); + size += ProtoSize::calc_uint32(1, msg.supported_modulations); return size; } #endif #ifdef USE_SERIAL_PROXY -bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyConfigureRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->baudrate = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.baudrate = value.as_varint(); break; - case 3: - this->flow_control = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.flow_control = value.as_bool(); break; - case 4: - this->parity = static_cast(value); + case proto_tag(4, WIRE_TYPE_VARINT): + msg.parity = static_cast(value.as_varint()); break; - case 5: - this->stop_bits = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.stop_bits = value.as_varint(); break; - case 6: - this->data_size = value; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.data_size = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +SerialProxyDataReceived::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SerialProxyDataReceived::calculate_size() const { +SerialProxyDataReceived::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint32(1, msg.instance); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyWriteRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - default: - return false; } - return true; } -bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->data = value.data(); - this->data_len = value.size(); +void SerialProxySetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.line_states = value.as_varint(); break; - } - default: - return false; } - return true; } -bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyGetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->line_states = value; - break; - default: - return false; } - return true; } -bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; - } - return true; -} -uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.line_states); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); return pos; } -uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { +uint32_t SerialProxyGetModemPinsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_uint32(1, this->line_states); - size += this->status ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.instance); + size += ProtoSize::calc_uint32(1, msg.line_states); + size += msg.status ? 2 : 0; return size; } -bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->type = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.type = static_cast(value.as_varint()); break; - default: - return false; } - return true; } -uint8_t *SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error_message); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.error_message); return pos; } -uint32_t SerialProxyRequestResponse::calculate_size() const { +uint32_t SerialProxyRequestResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += this->type ? 2 : 0; - size += this->status ? 2 : 0; - size += ProtoSize::calc_length(1, this->error_message.size()); + size += ProtoSize::calc_uint32(1, msg.instance); + size += msg.type ? 2 : 0; + size += msg.status ? 2 : 0; + size += ProtoSize::calc_length(1, msg.error_message.size()); return size; } +void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); + break; + } +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->min_interval = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.min_interval = value.as_varint(); break; - case 3: - this->max_interval = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.max_interval = value.as_varint(); break; - case 4: - this->latency = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.latency = value.as_varint(); break; - case 5: - this->timeout = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.timeout = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothSetConnectionParamsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.error); return pos; } -uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { +uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_int32(1, msg.error); return size; } #endif +#ifndef HAS_PROTO_MESSAGE_DUMP +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_COVER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_FAN +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_LIGHT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SWITCH +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_NOISE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_API_HOMEASSISTANT_STATES +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_USER_DEFINED_ACTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_CAMERA +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_CLIMATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_WATER_HEATER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_NUMBER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SELECT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SIREN +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_LOCK +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BUTTON +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_MEDIA_PLAYER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_VOICE_ASSISTANT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_ALARM_CONTROL_PANEL +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_TEXT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_DATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_TIME +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_VALVE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_DATETIME +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_UPDATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_ZWAVE_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SERIAL_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5c3429a63aa..dbf0fb49a1e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3, SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4, + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, @@ -366,6 +367,10 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_PORT_IN_USE = 5, SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6, }; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_PROTOCOL = 1, +}; #endif } // namespace enums @@ -377,7 +382,7 @@ class InfoResponseProtoMessage : public ProtoMessage { StringRef name{}; bool disabled_by_default{false}; #ifdef USE_ENTITY_ICON - StringRef icon{}; + StringRef icon{nullptr, 0}; // null until set, encode only #endif enums::EntityCategory entity_category{}; #ifdef USE_DEVICES @@ -419,13 +424,15 @@ class HelloRequest final : public ProtoDecodableMessage { StringRef client_info{}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class HelloResponse final : public ProtoMessage { public: @@ -438,8 +445,12 @@ class HelloResponse final : public ProtoMessage { uint32_t api_version_minor{0}; StringRef server_info{}; StringRef name{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -454,14 +465,21 @@ class DisconnectRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif enums::DisconnectReason reason{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class DisconnectResponse final : public ProtoMessage { public: @@ -507,8 +525,12 @@ class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -522,8 +544,12 @@ class DeviceInfo final : public ProtoMessage { uint32_t device_id{0}; StringRef name{}; uint32_t area_id{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -534,11 +560,15 @@ class DeviceInfo final : public ProtoMessage { #ifdef USE_SERIAL_PROXY class SerialProxyInfo final : public ProtoMessage { public: - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only enums::SerialProxyPortType port_type{}; uint32_t configured_line_states{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -608,8 +638,12 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_API_NOISE bool api_encryption_provisionable{false}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -621,8 +655,12 @@ class BluetoothProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; StringRef mac_address{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -634,8 +672,12 @@ class BluetoothProxyCapabilities final : public ProtoMessage { class VoiceAssistantCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -648,8 +690,12 @@ class ZWaveProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; uint32_t home_id{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -676,8 +722,12 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -705,10 +755,14 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_binary_sensor_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only bool is_status_binary_sensor{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -724,8 +778,12 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { #endif bool state{false}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -744,10 +802,14 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_tilt{false}; - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only bool supports_stop{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -764,8 +826,12 @@ class CoverStateResponse final : public StateResponseProtoMessage { float position{0.0f}; float tilt{0.0f}; enums::CoverOperation current_operation{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -784,13 +850,15 @@ class CoverCommandRequest final : public CommandProtoMessage { bool has_tilt{false}; float tilt{0.0f}; bool stop{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_FAN @@ -806,8 +874,12 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { bool supports_direction{false}; int32_t supported_speed_count{0}; const std::vector *supported_preset_modes{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -825,9 +897,13 @@ class FanStateResponse final : public StateResponseProtoMessage { bool oscillating{false}; enums::FanDirection direction{}; int32_t speed_level{0}; - StringRef preset_mode{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef preset_mode{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -851,14 +927,15 @@ class FanCommandRequest final : public CommandProtoMessage { int32_t speed_level{0}; bool has_preset_mode{false}; StringRef preset_mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_LIGHT @@ -873,8 +950,12 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; const FixedVector *effects{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -899,9 +980,13 @@ class LightStateResponse final : public StateResponseProtoMessage { float color_temperature{0.0f}; float cold_white{0.0f}; float warm_white{0.0f}; - StringRef effect{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef effect{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -941,14 +1026,15 @@ class LightCommandRequest final : public CommandProtoMessage { uint32_t flash_length{0}; bool has_effect{false}; StringRef effect{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SENSOR @@ -959,13 +1045,17 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_sensor_response"); } #endif - StringRef unit_of_measurement{}; + StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only int32_t accuracy_decimals{0}; bool force_update{false}; - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only enums::SensorStateClass state_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -981,8 +1071,12 @@ class SensorStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -999,9 +1093,13 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_switch_response"); } #endif bool assumed_state{false}; - StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef device_class{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1011,13 +1109,18 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { class SwitchStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 26; - static constexpr uint8_t ESTIMATED_SIZE = 11; + static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_state_response"); } #endif bool state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + bool missing_state{false}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1032,13 +1135,15 @@ class SwitchCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("switch_command_request"); } #endif bool state{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_TEXT_SENSOR @@ -1049,9 +1154,13 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } #endif - StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef device_class{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1065,10 +1174,14 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_sensor_state_response"); } #endif - StringRef state{}; + StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1085,12 +1198,15 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { #endif enums::LogLevel level{}; bool dump_config{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SubscribeLogsResponse final : public ProtoMessage { public: @@ -1106,8 +1222,12 @@ class SubscribeLogsResponse final : public ProtoMessage { this->message_ptr_ = data; this->message_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1124,12 +1244,15 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { #endif const uint8_t *key{nullptr}; uint16_t key_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: @@ -1139,8 +1262,12 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); } #endif bool success{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1151,10 +1278,14 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_SERVICES class HomeassistantServiceMap final : public ProtoMessage { public: - StringRef key{}; - StringRef value{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef key{nullptr, 0}; // null until set, encode only + StringRef value{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1168,7 +1299,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } #endif - StringRef service{}; + StringRef service{nullptr, 0}; // null until set, encode only FixedVector data{}; FixedVector data_template{}; FixedVector variables{}; @@ -1180,10 +1311,14 @@ class HomeassistantActionRequest final : public ProtoMessage { bool wants_response{false}; #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - StringRef response_template{}; + StringRef response_template{nullptr, 0}; // null until set, encode only #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1206,13 +1341,15 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1223,11 +1360,15 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_home_assistant_state_response"); } #endif - StringRef entity_id{}; - StringRef attribute{}; + StringRef entity_id{nullptr, 0}; // null until set, encode only + StringRef attribute{nullptr, 0}; // null until set, encode only bool once{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1244,12 +1385,15 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { StringRef entity_id{}; StringRef state{}; StringRef attribute{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif class GetTimeRequest final : public ProtoMessage { @@ -1273,12 +1417,15 @@ class DSTRule final : public ProtoDecodableMessage { uint32_t month{0}; uint32_t week{0}; uint32_t day_of_week{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ParsedTimezone final : public ProtoDecodableMessage { public: @@ -1286,13 +1433,15 @@ class ParsedTimezone final : public ProtoDecodableMessage { int32_t dst_offset_seconds{0}; DSTRule dst_start{}; DSTRule dst_end{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class GetTimeResponse final : public ProtoDecodableMessage { public: @@ -1304,27 +1453,33 @@ class GetTimeResponse final : public ProtoDecodableMessage { uint32_t epoch_seconds{0}; ParsedTimezone parsed_timezone{}; bool has_parsed_timezone{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only enums::ServiceArgType type{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{}; + StringRef description{nullptr, 0}; // null until set, encode only #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef example{}; + StringRef example{nullptr, 0}; // null until set, encode only #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1338,15 +1493,19 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{}; + StringRef description{nullptr, 0}; // null until set, encode only #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1370,9 +1529,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: @@ -1395,9 +1552,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1410,13 +1565,17 @@ class ExecuteServiceResponse final : public ProtoMessage { #endif uint32_t call_id{0}; bool success{false}; - StringRef error_message{}; + StringRef error_message{nullptr, 0}; // null until set, encode only #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1432,8 +1591,12 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1454,8 +1617,12 @@ class CameraImageResponse final : public StateResponseProtoMessage { this->data_len_ = len; } bool done{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1471,12 +1638,15 @@ class CameraImageRequest final : public ProtoDecodableMessage { #endif bool single{false}; bool stream{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_CLIMATE @@ -1506,8 +1676,12 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; enums::TemperatureUnit temperature_unit{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1517,7 +1691,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { class ClimateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 47; - static constexpr uint8_t ESTIMATED_SIZE = 68; + static constexpr uint8_t ESTIMATED_SIZE = 71; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_state_response"); } #endif @@ -1529,13 +1703,18 @@ class ClimateStateResponse final : public StateResponseProtoMessage { enums::ClimateAction action{}; enums::ClimateFanMode fan_mode{}; enums::ClimateSwingMode swing_mode{}; - StringRef custom_fan_mode{}; + StringRef custom_fan_mode{nullptr, 0}; // null until set, encode only enums::ClimatePreset preset{}; - StringRef custom_preset{}; + StringRef custom_preset{nullptr, 0}; // null until set, encode only float current_humidity{0.0f}; float target_humidity{0.0f}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + bool missing_state{false}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1569,14 +1748,15 @@ class ClimateCommandRequest final : public CommandProtoMessage { StringRef custom_preset{}; bool has_target_humidity{false}; float target_humidity{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_WATER_HEATER @@ -1593,8 +1773,12 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; enums::TemperatureUnit temperature_unit{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1604,7 +1788,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { class WaterHeaterStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 133; - static constexpr uint8_t ESTIMATED_SIZE = 35; + static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); } #endif @@ -1614,8 +1798,13 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + bool missing_state{false}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1635,13 +1824,15 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_NUMBER @@ -1655,11 +1846,15 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { float min_value{0.0f}; float max_value{0.0f}; float step{0.0f}; - StringRef unit_of_measurement{}; + StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only enums::NumberMode mode{}; - StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef device_class{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1675,8 +1870,12 @@ class NumberStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1691,13 +1890,15 @@ class NumberCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("number_command_request"); } #endif float state{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SELECT @@ -1709,8 +1910,12 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } #endif const FixedVector *options{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1724,10 +1929,14 @@ class SelectStateResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("select_state_response"); } #endif - StringRef state{}; + StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1742,14 +1951,15 @@ class SelectCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("select_command_request"); } #endif StringRef state{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SIREN @@ -1763,8 +1973,12 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { const FixedVector *tones{}; bool supports_duration{false}; bool supports_volume{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1779,8 +1993,12 @@ class SirenStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("siren_state_response"); } #endif bool state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1802,14 +2020,15 @@ class SirenCommandRequest final : public CommandProtoMessage { uint32_t duration{0}; bool has_volume{false}; float volume{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_LOCK @@ -1823,9 +2042,13 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_open{false}; bool requires_code{false}; - StringRef code_format{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef code_format{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1840,8 +2063,12 @@ class LockStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("lock_state_response"); } #endif enums::LockState state{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1858,14 +2085,15 @@ class LockCommandRequest final : public CommandProtoMessage { enums::LockCommand command{}; bool has_code{false}; StringRef code{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BUTTON @@ -1876,9 +2104,13 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } #endif - StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef device_class{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1892,25 +2124,31 @@ class ButtonCommandRequest final : public CommandProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("button_command_request"); } #endif + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_MEDIA_PLAYER class MediaPlayerSupportedFormat final : public ProtoMessage { public: - StringRef format{}; + StringRef format{nullptr, 0}; // null until set, encode only uint32_t sample_rate{0}; uint32_t num_channels{0}; enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1926,8 +2164,12 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { #endif std::vector supported_formats{}; uint32_t feature_flags{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1944,8 +2186,12 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage { enums::MediaPlayerState state{}; float volume{0.0f}; bool muted{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1967,14 +2213,15 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { StringRef media_url{}; bool has_announcement{false}; bool announcement{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -1986,12 +2233,15 @@ class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMes const LogString *message_name() const override { return LOG_STR("subscribe_bluetooth_le_advertisements_request"); } #endif uint32_t flags{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothLERawAdvertisement final : public ProtoMessage { public: @@ -2015,8 +2265,12 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { #endif std::array advertisements{}; uint16_t advertisements_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2036,12 +2290,15 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { enums::BluetoothDeviceRequestType request_type{}; bool has_address_type{false}; uint32_t address_type{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: @@ -2054,8 +2311,12 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage { bool connected{false}; uint32_t mtu{0}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2070,20 +2331,27 @@ class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_request"); } #endif uint64_t address{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTDescriptor final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; uint32_t short_uuid{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2097,8 +2365,12 @@ class BluetoothGATTCharacteristic final : public ProtoMessage { uint32_t properties{0}; FixedVector descriptors{}; uint32_t short_uuid{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2111,8 +2383,12 @@ class BluetoothGATTService final : public ProtoMessage { uint32_t handle{0}; FixedVector characteristics{}; uint32_t short_uuid{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2128,8 +2404,12 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage { #endif uint64_t address{0}; std::vector services{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2144,8 +2424,12 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); } #endif uint64_t address{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2161,12 +2445,15 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { #endif uint64_t address{0}; uint32_t handle{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTReadResponse final : public ProtoMessage { public: @@ -2183,8 +2470,12 @@ class BluetoothGATTReadResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2203,13 +2494,15 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { bool response{false}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2220,12 +2513,15 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { #endif uint64_t address{0}; uint32_t handle{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2238,13 +2534,15 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { uint32_t handle{0}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: @@ -2256,12 +2554,15 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { uint64_t address{0}; uint32_t handle{0}; bool enable{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: @@ -2278,8 +2579,12 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2296,8 +2601,12 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage { uint32_t free{0}; uint32_t limit{0}; std::array allocated{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2314,8 +2623,12 @@ class BluetoothGATTErrorResponse final : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2331,8 +2644,12 @@ class BluetoothGATTWriteResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2348,8 +2665,12 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2366,8 +2687,12 @@ class BluetoothDevicePairingResponse final : public ProtoMessage { uint64_t address{0}; bool paired{false}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2384,8 +2709,12 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2402,8 +2731,12 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2422,8 +2755,12 @@ class BluetoothScannerStateResponse final : public ProtoMessage { enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; enums::BluetoothScannerMode configured_mode{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2438,12 +2775,15 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_set_mode_request"); } #endif enums::BluetoothScannerMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_VOICE_ASSISTANT @@ -2456,20 +2796,27 @@ class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { #endif bool subscribe{false}; uint32_t flags{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAudioSettings final : public ProtoMessage { public: uint32_t noise_suppression_level{0}; uint32_t auto_gain{0}; float volume_multiplier{0.0f}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2484,12 +2831,16 @@ class VoiceAssistantRequest final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); } #endif bool start{false}; - StringRef conversation_id{}; + StringRef conversation_id{nullptr, 0}; // null until set, encode only uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; - StringRef wake_word_phrase{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef wake_word_phrase{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2505,23 +2856,29 @@ class VoiceAssistantResponse final : public ProtoDecodableMessage { #endif uint32_t port{0}; bool error{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantEventData final : public ProtoDecodableMessage { public: StringRef name{}; StringRef value{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: @@ -2532,13 +2889,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { #endif enums::VoiceAssistantEvent event_type{}; std::vector data{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: @@ -2552,15 +2911,21 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { bool end{false}; const uint8_t *data2{nullptr}; uint16_t data2_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: @@ -2575,13 +2940,15 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { uint32_t total_seconds{0}; uint32_t seconds_left{0}; bool is_active{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: @@ -2594,13 +2961,15 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { StringRef text{}; StringRef preannounce_media_id{}; bool start_conversation{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: @@ -2610,8 +2979,12 @@ class VoiceAssistantAnnounceFinished final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); } #endif bool success{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2620,11 +2993,15 @@ class VoiceAssistantAnnounceFinished final : public ProtoMessage { }; class VoiceAssistantWakeWord final : public ProtoMessage { public: - StringRef id{}; - StringRef wake_word{}; + StringRef id{nullptr, 0}; // null until set, encode only + StringRef wake_word{nullptr, 0}; // null until set, encode only std::vector trained_languages{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2640,13 +3017,15 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { uint32_t model_size{0}; StringRef model_hash{}; StringRef url{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: @@ -2656,12 +3035,15 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_request"); } #endif std::vector external_wake_words{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: @@ -2673,8 +3055,12 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage { std::vector available_wake_words{}; const std::vector *active_wake_words{}; uint32_t max_active_wake_words{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2689,12 +3075,15 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_set_configuration"); } #endif std::vector active_wake_words{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -2708,8 +3097,12 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2724,8 +3117,12 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); } #endif enums::AlarmControlPanelState state{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2741,14 +3138,15 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { #endif enums::AlarmControlPanelStateCommand command{}; StringRef code{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_TEXT @@ -2761,10 +3159,14 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { #endif uint32_t min_length{0}; uint32_t max_length{0}; - StringRef pattern{}; + StringRef pattern{nullptr, 0}; // null until set, encode only enums::TextMode mode{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2778,10 +3180,14 @@ class TextStateResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_state_response"); } #endif - StringRef state{}; + StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2796,14 +3202,15 @@ class TextCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("text_command_request"); } #endif StringRef state{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_DATE @@ -2814,8 +3221,12 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2833,8 +3244,12 @@ class DateStateResponse final : public StateResponseProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2851,13 +3266,15 @@ class DateCommandRequest final : public CommandProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_TIME @@ -2868,8 +3285,12 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2887,8 +3308,12 @@ class TimeStateResponse final : public StateResponseProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2905,13 +3330,15 @@ class TimeCommandRequest final : public CommandProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_EVENT @@ -2922,10 +3349,14 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only const FixedVector *event_types{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2939,9 +3370,13 @@ class EventResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("event_response"); } #endif - StringRef event_type{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef event_type{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2957,12 +3392,16 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_valve_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2978,8 +3417,12 @@ class ValveStateResponse final : public StateResponseProtoMessage { #endif float position{0.0f}; enums::ValveOperation current_operation{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2996,13 +3439,15 @@ class ValveCommandRequest final : public CommandProtoMessage { bool has_position{false}; float position{0.0f}; bool stop{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_DATETIME @@ -3013,8 +3458,12 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3030,8 +3479,12 @@ class DateTimeStateResponse final : public StateResponseProtoMessage { #endif bool missing_state{false}; uint32_t epoch_seconds{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3046,13 +3499,15 @@ class DateTimeCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("date_time_command_request"); } #endif uint32_t epoch_seconds{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_UPDATE @@ -3063,9 +3518,13 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } #endif - StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef device_class{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3083,13 +3542,17 @@ class UpdateStateResponse final : public StateResponseProtoMessage { bool in_progress{false}; bool has_progress{false}; float progress{0.0f}; - StringRef current_version{}; - StringRef latest_version{}; - StringRef title{}; - StringRef release_summary{}; - StringRef release_url{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef current_version{nullptr, 0}; // null until set, encode only + StringRef latest_version{nullptr, 0}; // null until set, encode only + StringRef title{nullptr, 0}; // null until set, encode only + StringRef release_summary{nullptr, 0}; // null until set, encode only + StringRef release_url{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3104,13 +3567,15 @@ class UpdateCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("update_command_request"); } #endif enums::UpdateCommand command{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_ZWAVE_PROXY @@ -3123,14 +3588,21 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { #endif const uint8_t *data{nullptr}; uint16_t data_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ZWaveProxyRequest final : public ProtoDecodableMessage { public: @@ -3142,15 +3614,21 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { enums::ZWaveProxyRequestType type{}; const uint8_t *data{nullptr}; uint16_t data_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ZWaveProxyRequestResponse final : public ProtoMessage { public: @@ -3161,8 +3639,12 @@ class ZWaveProxyRequestResponse final : public ProtoMessage { #endif enums::ZWaveProxyRequestType type{}; enums::ZWaveProxyStatus status{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3180,8 +3662,12 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { #endif uint32_t capabilities{0}; uint32_t receiver_frequency{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3207,14 +3693,15 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { uint16_t timings_length_{0}; uint16_t timings_count_{0}; uint32_t modulation{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class InfraredRFReceiveEvent final : public ProtoMessage { public: @@ -3228,8 +3715,12 @@ class InfraredRFReceiveEvent final : public ProtoMessage { #endif uint32_t key{0}; const std::vector *timings{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3249,8 +3740,12 @@ class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage uint32_t frequency_min{0}; uint32_t frequency_max{0}; uint32_t supported_modulations{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3272,12 +3767,15 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { enums::SerialProxyParity parity{}; uint32_t stop_bits{0}; uint32_t data_size{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyDataReceived final : public ProtoMessage { public: @@ -3293,8 +3791,12 @@ class SerialProxyDataReceived final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3311,13 +3813,15 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { uint32_t instance{0}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3328,12 +3832,15 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; uint32_t line_states{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3343,12 +3850,15 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_request"); } #endif uint32_t instance{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: @@ -3360,8 +3870,12 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage { uint32_t instance{0}; uint32_t line_states{0}; enums::SerialProxyStatus status{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3377,12 +3891,15 @@ class SerialProxyRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; enums::SerialProxyRequestType type{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyRequestResponse final : public ProtoMessage { public: @@ -3394,15 +3911,38 @@ class SerialProxyRequestResponse final : public ProtoMessage { uint32_t instance{0}; enums::SerialProxyRequestType type{}; enums::SerialProxyStatus status{}; - StringRef error_message{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef error_message{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: }; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 152; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } +#endif + uint32_t instance{0}; + enums::SerialProxyMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); +}; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { @@ -3417,12 +3957,15 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { uint32_t max_interval{0}; uint32_t latency{0}; uint32_t timeout{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: @@ -3433,8 +3976,12 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #endif uint64_t address{0}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index dced81ee307..d854a0731be 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string(enums return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE"); case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -878,6 +880,16 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_PROTOCOL: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif const char *HelloRequest::dump_to(DumpBuffer &out) const { @@ -1330,6 +1342,7 @@ const char *SwitchStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *SwitchCommandRequest::dump_to(DumpBuffer &out) const { @@ -1672,6 +1685,7 @@ const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *ClimateCommandRequest::dump_to(DumpBuffer &out) const { @@ -1739,6 +1753,7 @@ const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("state"), this->state); dump_field(out, ESPHOME_PSTR("target_temperature_low"), this->target_temperature_low); dump_field(out, ESPHOME_PSTR("target_temperature_high"), this->target_temperature_high); + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *WaterHeaterCommandRequest::dump_to(DumpBuffer &out) const { @@ -2805,6 +2820,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 65c7b8858cc..172062be636 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_device_capabilities_request(); break; } +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_request(msg); + break; + } +#endif default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6abdf7093e1..a4dfd6a3663 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -235,6 +235,9 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e9..2a89d31a74a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -5,7 +5,6 @@ #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -29,6 +28,29 @@ static const char *const TAG = "api"; // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_API_NOISE +static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL; +#endif + +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +bool load_saved_noise_psk(noise::psk_t &out) { + SavedNoisePsk saved; +#ifdef USE_PREFERENCE_KEY_LOOKUP + const bool loaded = + global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast(&saved), sizeof(saved)); +#else + // Slot backends need the reservation walk; it only lands on the record when the reservations before + // it match a normal boot, otherwise the type checked checksum fails the load + const bool loaded = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true).load(&saved); +#endif + // The all-zeros record means no key + if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk)) + return false; + out = saved.psk; + return true; +} +#endif + APIServer::APIServer() { global_api_server = this; } void APIServer::socket_failed_(const LogString *msg) { @@ -38,13 +60,10 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { - ControllerRegistry::register_controller(this); - #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; - this->noise_pref_ = global_preferences->make_preference(hash, true); + this->noise_pref_ = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true); #ifndef USE_API_NOISE_PSK_FROM_YAML // A cleared record loads fine but holds no key if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { @@ -433,8 +452,9 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call // Home Assistant subscribes to actions shortly *after* authenticating, so actions // fired right at connection time (on_client_connected, on_time_sync, ...) can // arrive before the subscription and are lost - warn instead of failing silently. - ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", - call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), call.service.c_str(), + ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s", + call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), + static_cast(call.service.size()), call.service.empty() ? "" : call.service.c_str(), this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)") : LOG_STR_LITERAL("no client connected")); } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11a..49cf77ca5af 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,7 +14,7 @@ #include "esphome/components/socket/socket.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/controller.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" #ifdef USE_PROVISIONING @@ -43,9 +43,13 @@ struct SavedNoisePsk { noise::psk_t psk; } PACKED; // NOLINT #endif +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when +/// there is no key +bool load_saved_noise_psk(noise::psk_t &out); +#endif -class APIServer final : public Component, - public Controller +class APIServer final : public Component #ifdef USE_CAMERA , public camera::CameraListener @@ -89,58 +93,58 @@ class APIServer final : public Component, void handle_disconnect(APIConnection *conn); #ifdef USE_BINARY_SENSOR - void on_binary_sensor_update(binary_sensor::BinarySensor *obj) override; + void on_binary_sensor_update(binary_sensor::BinarySensor *obj); #endif #ifdef USE_COVER - void on_cover_update(cover::Cover *obj) override; + void on_cover_update(cover::Cover *obj); #endif #ifdef USE_FAN - void on_fan_update(fan::Fan *obj) override; + void on_fan_update(fan::Fan *obj); #endif #ifdef USE_LIGHT - void on_light_update(light::LightState *obj) override; + void on_light_update(light::LightState *obj); #endif #ifdef USE_SENSOR - void on_sensor_update(sensor::Sensor *obj) override; + void on_sensor_update(sensor::Sensor *obj); #endif #ifdef USE_SWITCH - void on_switch_update(switch_::Switch *obj) override; + void on_switch_update(switch_::Switch *obj); #endif #ifdef USE_TEXT_SENSOR - void on_text_sensor_update(text_sensor::TextSensor *obj) override; + void on_text_sensor_update(text_sensor::TextSensor *obj); #endif #ifdef USE_CLIMATE - void on_climate_update(climate::Climate *obj) override; + void on_climate_update(climate::Climate *obj); #endif #ifdef USE_NUMBER - void on_number_update(number::Number *obj) override; + void on_number_update(number::Number *obj); #endif #ifdef USE_DATETIME_DATE - void on_date_update(datetime::DateEntity *obj) override; + void on_date_update(datetime::DateEntity *obj); #endif #ifdef USE_DATETIME_TIME - void on_time_update(datetime::TimeEntity *obj) override; + void on_time_update(datetime::TimeEntity *obj); #endif #ifdef USE_DATETIME_DATETIME - void on_datetime_update(datetime::DateTimeEntity *obj) override; + void on_datetime_update(datetime::DateTimeEntity *obj); #endif #ifdef USE_TEXT - void on_text_update(text::Text *obj) override; + void on_text_update(text::Text *obj); #endif #ifdef USE_SELECT - void on_select_update(select::Select *obj) override; + void on_select_update(select::Select *obj); #endif #ifdef USE_LOCK - void on_lock_update(lock::Lock *obj) override; + void on_lock_update(lock::Lock *obj); #endif #ifdef USE_VALVE - void on_valve_update(valve::Valve *obj) override; + void on_valve_update(valve::Valve *obj); #endif #ifdef USE_MEDIA_PLAYER - void on_media_player_update(media_player::MediaPlayer *obj) override; + void on_media_player_update(media_player::MediaPlayer *obj); #endif #ifdef USE_WATER_HEATER - void on_water_heater_update(water_heater::WaterHeater *obj) override; + void on_water_heater_update(water_heater::WaterHeater *obj); #endif #ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_action(const HomeassistantActionRequest &call); @@ -183,13 +187,13 @@ class APIServer final : public Component, #endif #ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override; + void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); #endif #ifdef USE_EVENT - void on_event(event::Event *obj) override; + void on_event(event::Event *obj); #endif #ifdef USE_UPDATE - void on_update(update::UpdateEntity *obj) override; + void on_update(update::UpdateEntity *obj); #endif #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); @@ -314,7 +318,7 @@ class APIServer final : public Component, #endif // 4-byte aligned types - uint32_t reboot_timeout_{300000}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. @@ -351,8 +355,8 @@ class APIServer final : public Component, #endif // Group smaller types together - uint16_t port_{6053}; - uint16_t batch_delay_{100}; + uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py + uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py // Connection limits - these defaults will be overridden by config values // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 236e4a474a1..fd1a9519dcd 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -195,6 +195,12 @@ void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t * ESP_LOGE(TAG, "Proto encode bounds check failed in %s: need %zu bytes, %td available", caller, bytes, end - pos); abort(); } +void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) { + if (end == expected) + return; + ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected); + abort(); +} void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) { if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) { ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes, @@ -210,77 +216,78 @@ void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expe #endif -void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { +void ProtoDecodableMessage::decode_fields(void *msg, const uint8_t *buffer, size_t length, DecodeFieldFn field) { const uint8_t *ptr = buffer; const uint8_t *end = buffer + length; - while (ptr < end) { - // Parse field header - ptr < end guarantees len >= 1 + // Single-byte varints dominate, so that case advances the cursor inline. + auto read_varint = [&](proto_varint_value_t &value) ESPHOME_ALWAYS_INLINE { + if (ptr == end) + return false; + if (*ptr < 0x80) [[likely]] { + value = *ptr++; + return true; + } auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr); - if (!res.has_value()) { + if (!res.has_value()) + return false; + value = res.value; + ptr += res.consumed; + return true; + }; + + while (ptr < end) { + proto_varint_value_t tag_value; + if (!read_varint(tag_value)) { ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer)); return; } - uint32_t tag = static_cast(res.value); + uint32_t tag = static_cast(tag_value); uint32_t field_type = tag & WIRE_TYPE_MASK; - uint32_t field_id = tag >> 3; - ptr += res.consumed; + // Length-delimited fields move this past the length prefix + const uint8_t *data = ptr; + proto_varint_value_t scalar; - switch (field_type) { - case WIRE_TYPE_VARINT: { // VarInt - res = ProtoVarInt::parse(ptr, end - ptr); - if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); - return; - } - if (!this->decode_varint(field_id, res.value)) { - ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu64 "!", field_id, - static_cast(res.value)); - } - ptr += res.consumed; - break; - } - case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - res = ProtoVarInt::parse(ptr, end - ptr); - if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); - return; - } - uint32_t field_length = static_cast(res.value); - ptr += res.consumed; - if (field_length > static_cast(end - ptr)) { - ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); - return; - } - if (!this->decode_length(field_id, ProtoLengthDelimited(ptr, field_length))) { - ESP_LOGV(TAG, "Cannot decode Length Delimited field %" PRIu32 "!", field_id); - } - ptr += field_length; - break; - } - case WIRE_TYPE_FIXED32: { // 32-bit - if (end - ptr < 4) { - ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); - return; - } - uint32_t val; -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - // Protobuf fixed32 is little-endian — direct load on LE platforms - memcpy(&val, ptr, 4); -#else - val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); -#endif - if (!this->decode_32bit(field_id, Proto32Bit(val))) { - ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val); - } - ptr += 4; - break; - } - default: - ESP_LOGV(TAG, "Invalid field type %" PRIu32 " at offset %ld", field_type, (long) (ptr - buffer)); + if (field_type == WIRE_TYPE_VARINT) [[likely]] { + if (!read_varint(scalar)) { + ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); return; + } + } else { + switch (field_type) { + case WIRE_TYPE_LENGTH_DELIMITED: { + proto_varint_value_t length_value; + if (!read_varint(length_value)) { + ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); + return; + } + uint32_t field_length = static_cast(length_value); + if (field_length > static_cast(end - ptr)) { + ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); + return; + } + data = ptr; + scalar = field_length; + ptr += field_length; + break; + } + case WIRE_TYPE_FIXED32: { + if (end - ptr < 4) { + ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); + return; + } + // Byte loads instead of memcpy: ESP-IDF passes -fno-builtin-memcpy, which made this a call + scalar = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); + ptr += 4; + break; + } + default: + ESP_LOGV(TAG, "Invalid field type %" PRIu32 " at offset %ld", field_type, (long) (ptr - buffer)); + return; + } } + field(msg, tag, data, scalar); } } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index a226e080e8c..c112b0f4159 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -10,6 +10,7 @@ #include #include +#include #include #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE @@ -170,40 +171,43 @@ class ProtoVarInt { class ProtoMessage; class ProtoSize; -class ProtoLengthDelimited { +/// Case label for decode_field(): the wire tag of a field, so a field that arrives with another wire +/// type matches no case. +constexpr uint32_t proto_tag(uint32_t field_id, uint32_t wire_type) { return (field_id << 3) | wire_type; } + +/// One decoded field: the payload pointer and a scalar holding the varint or fixed32 value, or the +/// length of a length-delimited field. The wire type in the tag says which applies; accessors do not check. +class ProtoFieldValue { public: - explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {} - std::string as_string() const { return std::string(reinterpret_cast(this->value_), this->length_); } + ProtoFieldValue(const uint8_t *data, proto_varint_value_t scalar) : data_(data), scalar_(scalar) {} - // Direct access to raw data without string allocation - const uint8_t *data() const { return this->value_; } - size_t size() const { return this->length_; } + proto_varint_value_t as_varint() const { return this->scalar_; } + // A bool is sent as 0 or 1, so the low word is enough and saves a second compare with 64 bit varints + bool as_bool() const { return static_cast(this->scalar_) != 0; } - /// Decode the length-delimited data into a message instance. + // Length-delimited accessors + const uint8_t *data() const { return this->data_; } + size_t size() const { return static_cast(this->scalar_); } + std::string as_string() const { return std::string(reinterpret_cast(this->data_), this->size()); } + /// Decode the length-delimited payload into a message instance. /// Template preserves concrete type so decode() resolves statically. - template void decode_to_message(T &msg) const; + template void decode_to_message(T &msg) const { msg.decode(this->data_, this->size()); } - protected: - const uint8_t *const value_; - const size_t length_; -}; - -class Proto32Bit { - public: - explicit Proto32Bit(uint32_t value) : value_(value) {} - uint32_t as_fixed32() const { return this->value_; } - int32_t as_sfixed32() const { return static_cast(this->value_); } + // Fixed32 accessors + uint32_t as_fixed32() const { return static_cast(this->scalar_); } + int32_t as_sfixed32() const { return static_cast(this->as_fixed32()); } float as_float() const { union { uint32_t raw; float value; } s{}; - s.raw = this->value_; + s.raw = this->as_fixed32(); return s.value; } - protected: - const uint32_t value_; + private: + const uint8_t *data_; + proto_varint_value_t scalar_; }; // NOTE: Proto64Bit class removed - wire type 1 (64-bit fixed) not supported @@ -221,6 +225,9 @@ class Proto32Bit { proto_check_bounds_failed(pos, n, proto_debug_end_, __builtin_FUNCTION()); \ } while (0) void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *end, const char *caller); +/// Aborts unless an encode body ended exactly where calculate_size() promised. A plain check rather than +/// assert(), so NDEBUG cannot switch it off. +void proto_check_encode_end(const uint8_t *end, const uint8_t *expected); #else #define PROTO_ENCODE_DEBUG_PARAM #define PROTO_ENCODE_DEBUG_ARG @@ -252,7 +259,7 @@ class ProtoWriteBuffer { * * Following https://protobuf.dev/programming-guides/encoding/#structure */ - void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw((field_id << 3) | type); } + void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); } /// Single-pass encode for repeated submessage elements. /// Thin template wrapper; all buffer work is in the non-template core. template void encode_sub_message(uint32_t field_id, const T &value); @@ -287,19 +294,31 @@ class ProtoWriteBuffer { uint8_t *pos_; }; +// A four byte unaligned store is a memcpy call on ESP-IDF (-fno-builtin-memcpy) and on ARM cores without +// unaligned access (Cortex-M0+, ARM9), so those targets share one outlined byte store helper per fixed32 +// field. Elsewhere the write inlines to a single store, or on ESP8266 to a few stores that measured +// faster than a call, so it stays inline. +#if defined(USE_ESP32) || (defined(__arm__) && !defined(__ARM_FEATURE_UNALIGNED)) +#define PROTO_OUTLINE_FOR_SIZE __attribute__((noinline)) +#define PROTO_FIXED32_BYTE_STORES true +#else +#define PROTO_OUTLINE_FOR_SIZE inline +#define PROTO_FIXED32_BYTE_STORES false +#endif + // Varint encoding thresholds — used by both proto_encode_* free functions and ProtoSize. constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128 constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384 -/// Static encode helpers for generated encode() functions. -/// Generated code hoists buffer.pos_ into a local uint8_t *__restrict__ pos, -/// then calls these methods which take pos by reference. No struct, no overhead. -/// For sub-messages, pos is synced back to buffer before the call and reloaded after. +/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and +/// returns it advanced, so outlined calls at -Os chain through the return register instead of a +/// stack slot. Helpers without a _force suffix skip fields holding the proto3 default. class ProtoEncode { public: /// Write a multi-byte varint directly through a pos pointer. template - static inline void encode_varint_raw_loop(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, T value) { + [[nodiscard]] static inline uint8_t *encode_varint_raw_loop(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + T value) { do { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value | 0x80); @@ -307,48 +326,49 @@ class ProtoEncode { } while (value > 0x7F); PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); + return pos; } - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint32_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t value) { if (value < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); - return; + return pos; } - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } /// Encode a varint that is expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths). - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint32_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw_short(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t value) { if (value < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); - return; + return pos; } if (value < VARINT_MAX_2_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 2); *pos++ = static_cast(value | 0x80); *pos++ = static_cast(value >> 7); - return; + return pos; } - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint64_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw_64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) { if (value < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); - return; + return pos; } - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } /// Encode a 48-bit MAC address (stored in a uint64) as varint. /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the /// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes /// with no per-byte branch. Falls back to the general loop otherwise. /// Caller must guarantee value fits in 48 bits (checked in debug builds). - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint64_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw_48bit(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) { #ifdef ESPHOME_DEBUG_API assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits"); #endif @@ -363,38 +383,39 @@ class ProtoEncode { pos[4] = static_cast((value >> 28) | 0x80); pos[5] = static_cast((value >> 35) | 0x80); pos[6] = static_cast(value >> 42); - pos += 7; - return; + return pos + 7; } - encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); } - static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint32_t field_id, uint32_t type) { - encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_field_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) { + return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, proto_tag(field_id, type)); } /// Write a single precomputed tag byte. Tag must be < 128. - static inline void ESPHOME_ALWAYS_INLINE write_raw_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint8_t b) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + write_raw_byte(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t b) { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = b; + return pos; } /// Reserve one byte for later backpatch (e.g., sub-message length). /// Advances pos past the reserved byte without writing a value. - static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + reserve_byte(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); - pos++; + return pos + 1; } /// Write raw bytes to the buffer (no tag, no length prefix). - static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - const void *data, size_t len) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *data, size_t len) { PROTO_ENCODE_CHECK_BOUNDS(pos, len); std::memcpy(pos, data, len); - pos += len; + return pos + len; } /// Encode tag + 1-byte length + raw string data. For strings with max_data_length < 128. /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). - static inline void encode_short_string_force(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, - const StringRef &ref) { + [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint8_t tag, const StringRef &ref) { #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -402,137 +423,191 @@ class ProtoEncode { pos[0] = tag; pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); - pos += 2 + ref.size(); + return pos + 2 + ref.size(); } - /// Write a precomputed tag byte + 32-bit value in one operation. - static inline void ESPHOME_ALWAYS_INLINE write_tag_and_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint8_t tag, uint32_t value) { + /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. + [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) { PROTO_ENCODE_CHECK_BOUNDS(pos, 5); pos[0] = tag; -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - std::memcpy(pos + 1, &value, 4); -#else - pos[1] = static_cast(value & 0xFF); - pos[2] = static_cast((value >> 8) & 0xFF); - pos[3] = static_cast((value >> 16) & 0xFF); - pos[4] = static_cast((value >> 24) & 0xFF); -#endif - pos += 5; + write_fixed32_le(pos + 1, value); + return pos + 5; } - static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const char *string, size_t len, bool force = false) { - if (len == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string + [[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const char *string, size_t len) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string // NOLINTNEXTLINE(readability-inconsistent-ifelse-braces) -- false positive on [[likely]] attribute if (len < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len); *pos++ = static_cast(len); } else { - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len); + pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len); PROTO_ENCODE_CHECK_BOUNDS(pos, len); } std::memcpy(pos, string, len); - pos += len; + return pos + len; } - static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const std::string &value, bool force = false) { - encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size(), force); + [[nodiscard]] static inline uint8_t *encode_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const char *string, size_t len) { + if (len == 0) + return pos; + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, string, len); } - static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const StringRef &ref, bool force = false) { - encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size(), force); + [[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const std::string &value) { + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size()); } - static inline void encode_bytes(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const uint8_t *data, size_t len, bool force = false) { - encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len, force); + [[nodiscard]] static inline uint8_t *encode_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const StringRef &ref) { + return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size()); } - static inline void encode_uint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - uint32_t value, bool force = false) { - if (value == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); - encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value); + [[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const StringRef &ref) { + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size()); } - static inline void encode_uint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - uint64_t value, bool force = false) { - if (value == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); - encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + [[nodiscard]] static inline uint8_t *encode_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len); } - static inline void encode_bool(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, bool value, - bool force = false) { - if (!value && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + [[nodiscard]] static inline uint8_t *encode_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len); + } + [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value); + } + [[nodiscard]] static inline uint8_t *encode_uint32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t value) { + if (value == 0) + return pos; + return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); + } + [[nodiscard]] static inline uint8_t *encode_uint64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint64_t value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + return encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + } + [[nodiscard]] static inline uint8_t *encode_uint64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint64_t value) { + if (value == 0) + return pos; + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); + } + [[nodiscard]] static inline uint8_t *encode_bool_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, bool value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = value ? 0x01 : 0x00; + return pos; } - static inline void encode_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - uint32_t value, bool force = false) { - if (value == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5); + [[nodiscard]] static inline uint8_t *encode_bool(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, bool value) { + if (!value) + return pos; + return encode_bool_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); + } + /// Tag + fixed32 for multi-byte tags; single-byte tags use write_tag_and_fixed32. + [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *encode_fixed32_force( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5); PROTO_ENCODE_CHECK_BOUNDS(pos, 4); -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - std::memcpy(pos, &value, 4); - pos += 4; -#else - *pos++ = (value >> 0) & 0xFF; - *pos++ = (value >> 8) & 0xFF; - *pos++ = (value >> 16) & 0xFF; - *pos++ = (value >> 24) & 0xFF; -#endif + write_fixed32_le(pos, value); + return pos + 4; + } + [[nodiscard]] static inline uint8_t *encode_fixed32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t value) { + if (value == 0) + return pos; + return encode_fixed32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); } // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally // not supported to reduce overhead on embedded systems. All ESPHome devices are // 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support // is needed in the future, the necessary encoding/decoding functions must be added. - static inline void encode_float(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, float value, - bool force = false) { - uint32_t raw = float_to_raw(value); - if (raw == 0 && !force) - return; - encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, raw); + [[nodiscard]] static inline uint8_t *encode_float(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, float value) { + return encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, float_to_raw(value)); } - static inline void encode_int32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int32_t value, - bool force = false) { + [[nodiscard]] static inline uint8_t *encode_float_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, float value) { + return encode_fixed32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, float_to_raw(value)); + } + [[nodiscard]] static inline uint8_t *encode_int32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { if (value < 0) { // negative int32 is always 10 byte long - encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); - return; + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - static inline void encode_int64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int64_t value, - bool force = false) { - encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + [[nodiscard]] static inline uint8_t *encode_int32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { + if (value == 0) + return pos; + return encode_int32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); } - static inline void encode_sint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - int32_t value, bool force = false) { - encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value), force); + [[nodiscard]] static inline uint8_t *encode_int64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - static inline void encode_sint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - int64_t value, bool force = false) { - encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value), force); + [[nodiscard]] static inline uint8_t *encode_int64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - /// Sub-message encoding: sync pos to buffer, delegate, get pos from return value. + [[nodiscard]] static inline uint8_t *encode_sint32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { + return encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value)); + } + [[nodiscard]] static inline uint8_t *encode_sint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { + return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value)); + } + [[nodiscard]] static inline uint8_t *encode_sint64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value)); + } + [[nodiscard]] static inline uint8_t *encode_sint64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value)); + } + /// Sub-message encoding: sync pos to buffer, delegate, read the cursor back. template - static inline void encode_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, ProtoWriteBuffer &buffer, - uint32_t field_id, const T &value) { + [[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) { buffer.set_pos(pos); buffer.encode_sub_message(field_id, value); - pos = buffer.get_pos(); + return buffer.get_pos(); } template - static inline void encode_optional_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) { + [[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + ProtoWriteBuffer &buffer, uint32_t field_id, + const T &value) { buffer.set_pos(pos); buffer.encode_optional_sub_message(field_id, value); - pos = buffer.get_pos(); + return buffer.get_pos(); + } + + private: + /// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM + /// without unaligned access), otherwise a memcpy the compiler folds into one store. Callers bounds check + /// and advance the cursor themselves. + static inline void ESPHOME_ALWAYS_INLINE write_fixed32_le(uint8_t *__restrict__ pos, uint32_t value) { + if constexpr (PROTO_FIXED32_BYTE_STORES) { + // Spelled out so the outlined helper does not itself become a memcpy call + pos[0] = static_cast(value); + pos[1] = static_cast(value >> 8); + pos[2] = static_cast(value >> 16); + pos[3] = static_cast(value >> 24); + } else { + const uint32_t le = convert_little_endian(value); + __builtin_memcpy(pos, &le, 4); + } } }; +#undef PROTO_OUTLINE_FOR_SIZE +#undef PROTO_FIXED32_BYTE_STORES #ifdef HAS_PROTO_MESSAGE_DUMP /** @@ -624,11 +699,12 @@ class DumpBuffer { class ProtoMessage { public: - // Non-virtual defaults for messages with no fields. - // Concrete message classes hide these with their own implementations. - // All call sites use templates to preserve the concrete type, so virtual - // dispatch is not needed. This eliminates per-message vtable entries for - // encode/calculate_size, saving ~1.3 KB of flash across all message types. + // Non-virtual defaults for messages with no fields; generated classes hide all four. The + // static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk. + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + return buffer.get_pos(); + } + static uint32_t calc_size_msg(const void *self) { return 0; } uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); } uint32_t calculate_size() const { return 0; } #ifdef HAS_PROTO_MESSAGE_DUMP @@ -648,7 +724,13 @@ class ProtoMessage { // Base class for messages that support decoding class ProtoDecodableMessage : public ProtoMessage { public: - void decode(const uint8_t *buffer, size_t length); + /// Stores one decoded field into \p msg; generated per message type. \p scalar is the varint or + /// fixed32 value, or the length of the length-delimited payload at \p data. An unknown field or + /// wrong wire type matches no case and is skipped. + using DecodeFieldFn = void (*)(void *msg, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); + /// Walk \p buffer and hand every field to \p field. The generated decode() passes the message's + /// own decode_field, so decodable messages carry no vtable. + static void decode_fields(void *msg, const uint8_t *buffer, size_t length, DecodeFieldFn field); /** * Count occurrences of a repeated field in a protobuf buffer. @@ -660,14 +742,15 @@ class ProtoDecodableMessage : public ProtoMessage { * @return Number of times the field appears in the buffer */ static uint32_t count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id); - - protected: - ~ProtoDecodableMessage() = default; - virtual bool decode_varint(uint32_t field_id, proto_varint_value_t value) { return false; } - virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } - virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } - // NOTE: decode_64bit removed - wire type 1 not supported + // The destructor stays accessible on purpose: the generated messages are aggregates that brace + // initialise sub message members, which copies a base temporary. That trades away the compile time + // guard against deleting through this type; messages are stack locals and never owned through a base + // pointer. ProtoMessage keeps its guard for the dump builds. }; +#ifndef HAS_PROTO_MESSAGE_DUMP +// decode() passes decode_field explicitly, so nothing here may add a vtable +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif class ProtoSize { public: @@ -792,7 +875,7 @@ class ProtoSize { * @return The number of bytes needed to encode the field ID and wire type */ static constexpr uint32_t field(uint32_t field_id, uint32_t type) { - uint32_t tag = (field_id << 3) | (type & WIRE_TYPE_MASK); + uint32_t tag = proto_tag(field_id, type & WIRE_TYPE_MASK); return varint(tag); } @@ -876,24 +959,14 @@ class ProtoSize { // Implementation of methods that depend on ProtoSize being fully defined -// Encode thunk — converts void* back to concrete type for direct encode() call -template uint8_t *proto_encode_msg(const void *msg, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { - return static_cast(msg)->encode(buf PROTO_ENCODE_DEBUG_ARG); -} - // Thin template wrapper; delegates to non-template core in proto.cpp. template inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) { - this->encode_sub_message(field_id, &value, &proto_encode_msg); + this->encode_sub_message(field_id, &value, &T::encode_msg); } // Thin template wrapper; delegates to non-template core. template inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) { - this->encode_optional_sub_message(field_id, value.calculate_size(), &value, &proto_encode_msg); -} - -// Template decode_to_message - preserves concrete type so decode() resolves statically -template void ProtoLengthDelimited::decode_to_message(T &msg) const { - msg.decode(this->value_, this->length_); + this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg); } template const char *proto_enum_to_string(T value); diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 6b1ae9651de..36e96e10819 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -4,7 +4,6 @@ #ifdef USE_API #include "esphome/core/component.h" #include "esphome/core/component_iterator.h" -#include "esphome/core/controller.h" namespace esphome::api { class APIConnection; diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 847b89f1214..4cf1e95f695 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -87,18 +87,8 @@ async def to_code(config: ConfigType) -> None: if out_of_range_mode_config := config.get(CONF_OUT_OF_RANGE_MODE): cg.add(var.set_out_of_range_mode(out_of_range_mode_config)) - if raw_position_config := config.get(CONF_RAW_POSITION): - sens = await sensor.new_sensor(raw_position_config) - cg.add(var.set_raw_position_sensor(sens)) - - if gain_config := config.get(CONF_GAIN): - sens = await sensor.new_sensor(gain_config) - cg.add(var.set_gain_sensor(sens)) - - if magnitude_config := config.get(CONF_MAGNITUDE): - sens = await sensor.new_sensor(magnitude_config) - cg.add(var.set_magnitude_sensor(sens)) - - if status_config := config.get(CONF_STATUS): - sens = await sensor.new_sensor(status_config) - cg.add(var.set_status_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RAW_POSITION, var.set_raw_position_sensor) + await sensors(CONF_GAIN, var.set_gain_sensor) + await sensors(CONF_MAGNITUDE, var.set_magnitude_sensor) + await sensors(CONF_STATUS, var.set_status_sensor) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index f70c5e999fd..3afab8422f9 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, @@ -33,8 +34,6 @@ CONF_F7 = "f7" CONF_F8 = "f8" CONF_NIR = "nir" -UNIT_COUNTS = "#" - AS7341_GAIN = as7341_ns.enum("AS7341Gain") GAIN_OPTIONS = { "X0.5": AS7341_GAIN.AS7341_GAIN_0_5X, diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index 10cbc981c71..853ff206c25 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -6,7 +6,6 @@ #include "esphome/components/network/util.h" #include "esphome/core/log.h" #include -#include namespace esphome::async_tcp { @@ -42,7 +41,15 @@ bool AsyncClient::connect(const char *host, uint16_t port) { return false; } - socket_->setblocking(false); + if (socket_->setblocking(false) != 0) { + // Capture before the log and close() clobber errno + const int saved_errno = errno; + ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno); + close(); + if (error_cb_) + error_cb_(error_arg_, this, saved_errno); + return false; + } int err = socket_->connect((struct sockaddr *) &addr, addrlen); if (err == 0) { @@ -97,45 +104,22 @@ void AsyncClient::loop() { return; if (connecting_) { - // For connecting, we need to check writability, not readability - // The Application's select() only monitors read FDs, so we do our own check here - // For ESP platforms lwip_select() might be faster, but this code isn't used - // on those platforms anyway. If it was, we'd fix the Application select() - // to report writability instead of doing it this way. - int fd = socket_->get_fd(); - if (fd < 0) { - ESP_LOGW(TAG, "Invalid socket fd"); - close(); - return; - } - - fd_set writefds; - FD_ZERO(&writefds); - FD_SET(fd, &writefds); - - struct timeval tv = {0, 0}; - int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv); - - if (ret > 0 && FD_ISSET(fd, &writefds)) { - int error = 0; - socklen_t len = sizeof(error); - if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) { + int err = 0; + switch (socket::poll_connect(*socket_, err)) { + case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED: connecting_ = false; connected_ = true; if (connect_cb_) connect_cb_(connect_arg_, this); - } else { - ESP_LOGW(TAG, "Connection failed: %d", error); + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR: + ESP_LOGW(TAG, "Connection failed: %d", err); close(); if (error_cb_) - error_cb_(error_arg_, this, error); - } - } else if (ret < 0) { - const int err = errno; - ESP_LOGE(TAG, "Select error: %d", err); - close(); - if (error_cb_) - error_cb_(error_arg_, this, err); + error_cb_(error_arg_, this, err); + break; } } else if (connected_) { // For connected sockets, use the Application's select() results diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 193e62f6157..a29e166745e 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] @@ -80,30 +78,21 @@ async def to_code(config: ConfigType) -> None: # Actions -AT581XResetAction = at581x_ns.class_("AT581XResetAction", automation.Action) -AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Action) -@automation.register_action( +automation.register_apply_action( "at581x.reset", - AT581XResetAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(AT581XComponent), - } - ), - synchronous=True, + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(AT581XComponent)}), + automation.ApplyCall("reset_hardware_frontend()"), ) -async def at581x_reset_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]) - return var + +def _megahertz(value: float) -> int: + return int(value / 1000000) + + +def _microamps(value: float) -> int: + return int(value * 1000000) RADAR_SETTINGS_SCHEMA = cv.Schema( @@ -111,7 +100,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( cv.Required(CONF_ID): cv.use_id(AT581XComponent), cv.Optional(CONF_HW_FRONTEND_RESET): cv.templatable(cv.boolean), cv.Optional(CONF_FREQUENCY, default="5800MHz"): cv.templatable( - cv.All(cv.frequency, cv.one_of(*RADAR_ALLOWED_FREQ)) + cv.All(cv.frequency, cv.one_of(*RADAR_ALLOWED_FREQ), _megahertz) ), cv.Optional(CONF_SENSING_DISTANCE, default=823): cv.templatable( cv.int_range(min=0, max=1023) @@ -123,7 +112,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( ) ), cv.Optional(CONF_POWER_CONSUMPTION, default="70uA"): cv.templatable( - cv.All(cv.current, cv.one_of(*RADAR_ALLOWED_CUR_CONSUMPTION)) + cv.All(cv.current, cv.one_of(*RADAR_ALLOWED_CUR_CONSUMPTION), _microamps) ), cv.Optional(CONF_PROTECT_TIME, default="1000ms"): cv.templatable( cv.All( @@ -165,60 +154,22 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( ) -@automation.register_action( +# i2c_write_config() must follow the setters: it flushes the staged values. +automation.register_apply_action( "at581x.settings", - AT581XSettingsAction, RADAR_SETTINGS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "set_frequency", cg.int_), + automation.ApplyField(CONF_SENSING_DISTANCE, "set_sensing_distance", cg.int_), + automation.ApplyField( + CONF_POWERON_SELFCHECK_TIME, "set_poweron_selfcheck_time", cg.int_ + ), + automation.ApplyField(CONF_POWER_CONSUMPTION, "set_power_consumption", cg.int_), + automation.ApplyField(CONF_PROTECT_TIME, "set_protect_time", cg.int_), + automation.ApplyField(CONF_TRIGGER_BASE, "set_trigger_base", cg.int_), + automation.ApplyField(CONF_TRIGGER_KEEP, "set_trigger_keep", cg.int_), + automation.ApplyField(CONF_STAGE_GAIN, "set_stage_gain", cg.int_), + automation.ApplyCall("i2c_write_config()"), + automation.ApplyField( + CONF_HW_FRONTEND_RESET, "reset_hardware_frontend_if", cg.bool_ + ), ) -async def at581x_settings_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]) - - # Radar configuration - if frontend_reset := config.get(CONF_HW_FRONTEND_RESET): - template_ = await cg.templatable(frontend_reset, args, cg.int8) - cg.add(var.set_hw_frontend_reset(template_)) - - if freq := config.get(CONF_FREQUENCY): - if not cg.is_template(freq): - freq = int(freq / 1000000) - template_ = await cg.templatable(freq, args, cg.int_) - cg.add(var.set_frequency(template_)) - - if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: - template_ = await cg.templatable(sens_dist, args, cg.int_) - cg.add(var.set_sensing_distance(template_)) - - if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int_) - cg.add(var.set_poweron_selfcheck_time(template_)) - - if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int_) - cg.add(var.set_protect_time(template_)) - - if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int_) - cg.add(var.set_trigger_base(template_)) - - if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int_) - cg.add(var.set_trigger_keep(template_)) - - if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: - template_ = await cg.templatable(stage_gain, args, cg.int_) - cg.add(var.set_stage_gain(template_)) - - if power := config.get(CONF_POWER_CONSUMPTION): - if not cg.is_template(power): - power = int(power * 1000000) - template_ = await cg.templatable(power, args, cg.int_) - cg.add(var.set_power_consumption(template_)) - - return var diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index 594395e96d8..df50d5142f8 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -38,6 +38,10 @@ class AT581XComponent final : public Component, public i2c::I2CDevice { bool i2c_write_config(); bool reset_hardware_frontend(); + void reset_hardware_frontend_if(bool reset) { + if (reset) + this->reset_hardware_frontend(); + } bool i2c_write_reg(uint8_t addr, uint8_t data); bool i2c_write_reg(uint8_t addr, uint32_t data); bool i2c_write_reg(uint8_t addr, uint16_t data); diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h deleted file mode 100644 index a732d2bcc79..00000000000 --- a/esphome/components/at581x/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -#include "at581x.h" - -namespace esphome::at581x { - -template class AT581XResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } -}; - -template class AT581XSettingsAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) - TEMPLATABLE_VALUE(int, frequency) - TEMPLATABLE_VALUE(int, sensing_distance) - TEMPLATABLE_VALUE(int, poweron_selfcheck_time) - TEMPLATABLE_VALUE(int, power_consumption) - TEMPLATABLE_VALUE(int, protect_time) - TEMPLATABLE_VALUE(int, trigger_base) - TEMPLATABLE_VALUE(int, trigger_keep) - TEMPLATABLE_VALUE(int, stage_gain) - - void play(const Ts &...x) { - if (this->frequency_.has_value()) { - int v = this->frequency_.value(x...); - this->parent_->set_frequency(v); - } - if (this->sensing_distance_.has_value()) { - int v = this->sensing_distance_.value(x...); - this->parent_->set_sensing_distance(v); - } - if (this->poweron_selfcheck_time_.has_value()) { - int v = this->poweron_selfcheck_time_.value(x...); - this->parent_->set_poweron_selfcheck_time(v); - } - if (this->power_consumption_.has_value()) { - int v = this->power_consumption_.value(x...); - this->parent_->set_power_consumption(v); - } - if (this->protect_time_.has_value()) { - int v = this->protect_time_.value(x...); - this->parent_->set_protect_time(v); - } - if (this->trigger_base_.has_value()) { - int v = this->trigger_base_.value(x...); - this->parent_->set_trigger_base(v); - } - if (this->trigger_keep_.has_value()) { - int v = this->trigger_keep_.value(x...); - this->parent_->set_trigger_keep(v); - } - if (this->stage_gain_.has_value()) { - int v = this->stage_gain_.value(x...); - this->parent_->set_stage_gain(v); - } - - // This actually perform all the modification on the system - this->parent_->i2c_write_config(); - - if (this->hw_frontend_reset_.has_value() && this->hw_frontend_reset_.value(x...) == true) { - this->parent_->reset_hardware_frontend(); - } - } -}; -} // namespace esphome::at581x diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 184b2e87339..cde1a41f657 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -85,18 +85,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) - if battery_voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(battery_voltage_config) - cg.add(var.set_battery_voltage(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 87db214233a..5a6b667535f 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -129,30 +129,15 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await spi.register_spi_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if reactive_power_config := config.get(CONF_REACTIVE_POWER): - sens = await sensor.new_sensor(reactive_power_config) - cg.add(var.set_reactive_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) - if forward_active_energy_config := config.get(CONF_FORWARD_ACTIVE_ENERGY): - sens = await sensor.new_sensor(forward_active_energy_config) - cg.add(var.set_forward_active_energy_sensor(sens)) - if reverse_active_energy_config := config.get(CONF_REVERSE_ACTIVE_ENERGY): - sens = await sensor.new_sensor(reverse_active_energy_config) - cg.add(var.set_reverse_active_energy_sensor(sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_freq_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FORWARD_ACTIVE_ENERGY, var.set_forward_active_energy_sensor) + await sensors(CONF_REVERSE_ACTIVE_ENERGY, var.set_reverse_active_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_freq_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_meter_constant(config[CONF_METER_CONSTANT])) cg.add(var.set_pl_const(config[CONF_PL_CONST])) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index d948b3741df..23701e78348 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -9,6 +9,10 @@ namespace esphome::atm90e32 { static const char *const TAG = "atm90e32"; +static const LogString *offset_calibration_name(bool power_offsets) { + return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); +} + static uint32_t pref_hash(const char *prefix, const char *name_space) { auto hash = fnv1_hash(prefix); return fnv1_hash_extend(hash, name_space); @@ -203,13 +207,12 @@ void ATM90E32Component::setup() { // Initialize flash storage for power offset calibrations uint32_t po_hash = pref_hash("_power_offset_calibration_", cs); - this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); + this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); bool migrated_power_offset = false; if (has_distinct_legacy_namespace) { uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs); - auto legacy_power_offset_pref = - global_preferences->make_preference(legacy_po_hash, true); - PowerOffsetCalibration power_offset_data[3]{}; + auto legacy_power_offset_pref = global_preferences->make_preference(legacy_po_hash, true); + OffsetCalibration power_offset_data[3]{}; int migration_status = migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data); migrated_power_offset = migration_status > 0; @@ -224,20 +227,20 @@ void ATM90E32Component::setup() { global_preferences->sync(); } - this->restore_offset_calibrations_(); - this->restore_power_offset_calibrations_(); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(this->voltage_offset_registers[phase], - static_cast(this->offset_phase_[phase].voltage_offset_)); + static_cast(this->offset_phase_[phase].first_offset)); this->write16_(this->current_offset_registers[phase], - static_cast(this->offset_phase_[phase].current_offset_)); + static_cast(this->offset_phase_[phase].second_offset)); this->write16_(this->power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].active_power_offset)); + static_cast(this->power_offset_phase_[phase].first_offset)); this->write16_(this->reactive_power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].reactive_power_offset)); + static_cast(this->power_offset_phase_[phase].second_offset)); } } @@ -317,8 +320,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_, - this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_); + this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset, + this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -335,10 +338,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].active_power_offset, - this->config_power_offset_phase_[phase].reactive_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset, + this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -372,7 +373,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs); } @@ -385,8 +386,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); } @@ -756,36 +756,68 @@ void ATM90E32Component::save_gain_calibration_to_memory_() { } } -void ATM90E32Component::save_offset_calibration_to_memory_() { +void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); - bool success = this->offset_pref_.save(&this->offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_offset_calibration_ = true; - for (bool &phase : this->offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs); - } -} + const LogString *name = offset_calibration_name(power_offsets); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; -void ATM90E32Component::save_power_offset_calibration_to_memory_() { - const char *cs = this->get_calibration_id_(); - bool success = this->power_offset_pref_.save(&this->power_offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_power_offset_calibration_ = true; - for (bool &phase : this->power_offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs); + const bool writes_verified = this->verify_offset_writes_(type); + bool saved = false; + bool synced = false; + if (writes_verified) { + saved = preference->save(offsets); + synced = global_preferences->sync(); } + + if (writes_verified && saved && synced) { + this->using_saved_calibrations_ = true; + *has_stored = true; + *restored = true; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs, + LOG_STR_ARG(name), LOG_STR_ARG(name)); + return; + } + + if (writes_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name)); + } + + for (uint8_t phase = 0; phase < 3; phase++) { + this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); + } + const bool rollback_verified = this->verify_offset_writes_(type); + + bool rollback_persisted = false; + if (writes_verified) { + OffsetCalibration rollback[3]{}; + prepare_offset_rollback(previous, previous_restored, rollback); + const bool rollback_saved = preference->save(&rollback); + const bool rollback_synced = global_preferences->sync(); + rollback_persisted = rollback_saved && rollback_synced; + if (!rollback_saved || !rollback_synced) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name)); + } + } + + *restored = previous_restored; + if (rollback_persisted) + *has_stored = previous_restored; + this->using_saved_calibrations_ = previous_using_saved; + if (!rollback_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs, + LOG_STR_ARG(name)); + return; + } + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name)); } void ATM90E32Component::run_offset_calibrations() { @@ -803,11 +835,16 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]}; + const bool previous_restored = this->restored_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); int16_t current_offset = calibrate_offset(phase, false); - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); @@ -815,7 +852,8 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); - this->save_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); } void ATM90E32Component::run_power_offset_calibrations() { @@ -834,18 +872,25 @@ void ATM90E32Component::run_power_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1], + this->power_offset_phase_[2]}; + const bool previous_restored = this->restored_power_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); int16_t reactive_offset = calibrate_power_offset(phase, true); - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->save_power_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } void ATM90E32Component::write_gains_to_registers_() { @@ -859,35 +904,26 @@ void ATM90E32Component::write_gains_to_registers_() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } -void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) { - // Save to runtime - this->offset_phase_[phase].voltage_offset_ = voltage_offset; - this->phase_[phase].voltage_offset_ = voltage_offset; +void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase]; + offsets.first_offset = first_offset; + offsets.second_offset = second_offset; + if (power_offsets) { + this->phase_[phase].active_power_offset_ = first_offset; + this->phase_[phase].reactive_power_offset_ = second_offset; + } else { + this->phase_[phase].voltage_offset_ = first_offset; + this->phase_[phase].current_offset_ = second_offset; + } - // Save to flash-storable struct - this->offset_phase_[phase].current_offset_ = current_offset; - this->phase_[phase].current_offset_ = current_offset; - - // Write to registers + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(voltage_offset_registers[phase], static_cast(voltage_offset)); - this->write16_(current_offset_registers[phase], static_cast(current_offset)); - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); -} - -void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) { - // Save to runtime - this->phase_[phase].active_power_offset_ = p_offset; - this->phase_[phase].reactive_power_offset_ = q_offset; - - // Save to flash-storable struct - this->power_offset_phase_[phase].active_power_offset = p_offset; - this->power_offset_phase_[phase].reactive_power_offset = q_offset; - - // Write to registers - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(this->power_offset_registers[phase], static_cast(p_offset)); - this->write16_(this->reactive_power_offset_registers[phase], static_cast(q_offset)); + this->write16_(first_registers[phase], static_cast(first_offset)); + this->write16_(second_registers[phase], static_cast(second_offset)); this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } @@ -947,89 +983,78 @@ void ATM90E32Component::restore_gain_calibrations_() { ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); } -void ATM90E32Component::restore_offset_calibrations_() { +void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); + const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset"); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + OffsetCalibration(*config_offsets)[3] = + power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; + const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_; + const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_; + for (uint8_t i = 0; i < 3; ++i) - this->config_offset_phase_[i] = this->offset_phase_[i]; - - bool have_data = this->offset_pref_.load(&this->offset_phase_); + (*config_offsets)[i] = (*offsets)[i]; + const bool have_data = preference->load(offsets); bool all_zero = true; if (have_data) { - for (auto &phase : this->offset_phase_) { - if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) { + for (const auto &phase : *offsets) { + if (phase.first_offset != 0 || phase.second_offset != 0) { all_zero = false; break; } } } - if (have_data && !all_zero) { - this->restored_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; phase++) { - auto &offset = this->offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_voltage_offset_[phase] && - offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_) - mismatch = true; - if (this->has_config_current_offset_[phase] && - offset.current_offset_ != this->config_offset_phase_[phase].current_offset_) - mismatch = true; - if (mismatch) - this->offset_calibration_mismatch_[phase] = true; + *has_stored = have_data && !all_zero; + *restored = false; + for (uint8_t phase = 0; phase < 3; phase++) { + mismatches[phase] = false; + if (*has_stored) { + mismatches[phase] = + (has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || + (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset); } - } else { + } + + if (!*has_stored) { for (uint8_t phase = 0; phase < 3; phase++) - this->offset_phase_[phase] = this->config_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs); + (*offsets)[phase] = (*config_offsets)[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name)); } for (uint8_t phase = 0; phase < 3; phase++) { - write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_, - this->offset_phase_[phase].current_offset_); + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); } -} - -void ATM90E32Component::restore_power_offset_calibrations_() { - const char *cs = this->get_calibration_id_(); - for (uint8_t i = 0; i < 3; ++i) - this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; - - bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); - - bool all_zero = true; - if (have_data) { - for (auto &phase : this->power_offset_phase_) { - if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) { - all_zero = false; - break; - } - } + const bool initial_values_verified = this->verify_offset_writes_(type); + if (initial_values_verified) { + const auto state = resolve_offset_restore_state(*has_stored, true, false); + *restored = state.restored; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name)); + return; } - if (have_data && !all_zero) { - this->restored_power_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; ++phase) { - auto &offset = this->power_offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_active_power_offset_[phase] && - offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset) - mismatch = true; - if (this->has_config_reactive_power_offset_[phase] && - offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset) - mismatch = true; - if (mismatch) - this->power_offset_calibration_mismatch_[phase] = true; - } + this->using_saved_calibrations_ = false; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + for (uint8_t phase = 0; phase < 3; phase++) { + (*offsets)[phase] = (*config_offsets)[phase]; + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); + } + const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type)); + *restored = state.restored; + if (state.values_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, + LOG_STR_ARG(name)); } else { - for (uint8_t phase = 0; phase < 3; ++phase) - this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs); - } - - for (uint8_t phase = 0; phase < 3; ++phase) { - write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs, + LOG_STR_ARG(name)); } } @@ -1084,14 +1109,14 @@ void ATM90E32Component::clear_gain_calibrations() { void ATM90E32Component::clear_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_offset_calibration_) { + if (!this->has_stored_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); return; @@ -1104,10 +1129,11 @@ void ATM90E32Component::clear_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = - this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0; + this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0; int16_t current_offset = - this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0; - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); } @@ -1117,6 +1143,7 @@ void ATM90E32Component::clear_offset_calibrations() { this->offset_pref_.save(&zero_offsets); // Clear stored values in flash global_preferences->sync(); + this->has_stored_offset_calibration_ = false; this->restored_offset_calibration_ = false; for (bool &phase : this->offset_calibration_mismatch_) phase = false; @@ -1126,15 +1153,14 @@ void ATM90E32Component::clear_offset_calibrations() { void ATM90E32Component::clear_power_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_power_offset_calibration_) { + if (!this->has_stored_power_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); return; @@ -1147,20 +1173,21 @@ void ATM90E32Component::clear_power_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t active_offset = - this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0; - int16_t reactive_offset = this->has_config_reactive_power_offset_[phase] - ? this->config_power_offset_phase_[phase].reactive_power_offset - : 0; - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0; + int16_t reactive_offset = + this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; this->power_offset_pref_.save(&zero_power_offsets); global_preferences->sync(); + this->has_stored_power_offset_calibration_ = false; this->restored_power_offset_calibration_ = false; for (bool &phase : this->power_offset_calibration_mismatch_) phase = false; @@ -1215,6 +1242,31 @@ bool ATM90E32Component::verify_gain_writes_() { return success; // Return true if all writes were successful, false otherwise } +bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + const char *cs = this->get_calibration_id_(); + const LogString *name = offset_calibration_name(power_offsets); + const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage"); + const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current"); + const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_; + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; + bool success = true; + for (uint8_t phase = 0; phase < 3; phase++) { + const uint16_t first = this->read16_(first_registers[phase]); + const uint16_t second = this->read16_(second_registers[phase]); + if (!offset_register_value_matches(first, offsets[phase].first_offset) || + !offset_register_value_matches(second, offsets[phase].second_offset)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name), + phase_labels[phase], LOG_STR_ARG(first_name), static_cast(first), offsets[phase].first_offset, + LOG_STR_ARG(second_name), static_cast(second), offsets[phase].second_offset); + success = false; + } + } + return success; +} + #ifdef USE_TEXT_SENSOR void ATM90E32Component::check_phase_status() { uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0); diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c636e5065a5..fe7d903962f 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,6 +13,40 @@ namespace esphome::atm90e32 { +inline bool offset_register_value_matches(uint16_t actual, int16_t expected) { + return actual == static_cast(expected); +} + +struct OffsetCalibration { + int16_t first_offset{0}; + int16_t second_offset{0}; +}; + +static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible"); + +enum class OffsetCalibrationType : uint8_t { + OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + OFFSET_CALIBRATION_TYPE_POWER, +}; + +struct OffsetRestoreState { + bool restored; + bool values_verified; +}; + +inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified, + bool fallback_values_verified) { + if (initial_values_verified) + return {has_stored_values, true}; + return {false, fallback_values_verified}; +} + +inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values, + OffsetCalibration (&rollback)[3]) { + for (uint8_t phase = 0; phase < 3; phase++) + rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{}; +} + class ATM90E32Component final : public PollingComponent, public spi::SPIDevice { @@ -71,19 +105,19 @@ class ATM90E32Component final : public PollingComponent, this->has_config_current_gain_[phase] = true; } void set_voltage_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].voltage_offset_ = offset; + this->offset_phase_[phase].first_offset = offset; this->has_config_voltage_offset_[phase] = true; } void set_current_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].current_offset_ = offset; + this->offset_phase_[phase].second_offset = offset; this->has_config_current_offset_[phase] = true; } void set_active_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].active_power_offset = offset; + this->power_offset_phase_[phase].first_offset = offset; this->has_config_active_power_offset_[phase] = true; } void set_reactive_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].reactive_power_offset = offset; + this->power_offset_phase_[phase].second_offset = offset; this->has_config_reactive_power_offset_[phase] = true; } void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } @@ -171,16 +205,16 @@ class ATM90E32Component final : public PollingComponent, float get_chip_temperature_(); bool get_publish_interval_flag_() { return publish_interval_flag_; }; void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; }; - void restore_offset_calibrations_(); - void restore_power_offset_calibrations_(); + void restore_offset_calibrations_(OffsetCalibrationType type); void restore_gain_calibrations_(); - void save_offset_calibration_to_memory_(); void save_gain_calibration_to_memory_(); - void save_power_offset_calibration_to_memory_(); - void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset); - void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset); + void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type); + void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type); void write_gains_to_registers_(); bool verify_gain_writes_(); + bool verify_offset_writes_(OffsetCalibrationType type); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); void log_calibration_status_(); const char *get_calibration_id_(); @@ -219,19 +253,10 @@ class ATM90E32Component final : public PollingComponent, uint32_t cumulative_reverse_active_energy_{0}; } phase_[3]; - struct OffsetCalibration { - int16_t voltage_offset_{0}; - int16_t current_offset_{0}; - } offset_phase_[3]; - + OffsetCalibration offset_phase_[3]; OffsetCalibration config_offset_phase_[3]; - - struct PowerOffsetCalibration { - int16_t active_power_offset{0}; - int16_t reactive_power_offset{0}; - } power_offset_phase_[3]; - - PowerOffsetCalibration config_power_offset_phase_[3]; + OffsetCalibration power_offset_phase_[3]; + OffsetCalibration config_power_offset_phase_[3]; struct GainCalibration { uint16_t voltage_gain{1}; @@ -265,6 +290,8 @@ class ATM90E32Component final : public PollingComponent, bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; const char *instance_id_{nullptr}; + bool has_stored_offset_calibration_{false}; + bool has_stored_power_offset_calibration_{false}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2a5304be77e..b882aaa6b75 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,10 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component( - name="esphome/esp-audio-libs", - ref="3.2.1", - ) + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") data = _get_data() diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index fe9ad9c9add..051395606c8 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -313,9 +313,10 @@ FileDecoderState AudioDecoder::decode_mp3_() { this->output_transfer_buffer_->increase_buffer_length( this->audio_stream_info_.value().frames_to_bytes(samples_decoded)); } - } else if (result == micro_mp3::MP3_STREAM_INFO_READY) { - // First successful header parse: capture stream info and resize the output buffer to fit one full frame. - // microMP3 always outputs 16-bit PCM. + } else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) { + // Header parsed: capture stream info and resize the output buffer to fit one full frame. + // microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its + // negative value it is documented as recoverable, so it must not reach the catch-all below. this->audio_stream_info_ = audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate()); this->free_buffer_required_ = diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 4678ed548c7..e69f33ac2d5 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); + if (this->file_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } return ESP_OK; } diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index a611549e58d..01fd4bb68a6 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le void AudioTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } } void AudioSinkTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } #ifdef USE_SPEAKER @@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() { } bool AudioTransferBuffer::has_buffered_data() const { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); @@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_ size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_read = 0; if (bytes_to_read > 0) { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); } @@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); } else #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_written = this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); } else if (this->sink_callback_ != nullptr) { @@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->speaker_->has_buffered_data() || (this->available() > 0)); } #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index c2bdfb6cb0b..7d4b51db047 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -1,9 +1,8 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_MIC_GAIN +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -12,8 +11,6 @@ IS_PLATFORM_COMPONENT = True audio_adc_ns = cg.esphome_ns.namespace("audio_adc") AudioAdc = audio_adc_ns.class_("AudioAdc") -SetMicGainAction = audio_adc_ns.class_("SetMicGainAction", automation.Action) - SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( { @@ -24,25 +21,11 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "audio_adc.set_mic_gain", - SetMicGainAction, SET_MIC_GAIN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MIC_GAIN, "set_mic_gain", cg.float_), ) -async def audio_adc_set_mic_gain_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, cg.float_) - cg.add(var.set_mic_gain(template_)) - - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h deleted file mode 100644 index fc7af256228..00000000000 --- a/esphome/components/audio_adc/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "audio_adc.h" - -namespace esphome::audio_adc { - -template class SetMicGainAction final : public Action { - public: - explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} - - TEMPLATABLE_VALUE(float, mic_gain) - - void play(const Ts &...x) override { this->audio_adc_->set_mic_gain(this->mic_gain_.value(x...)); } - - protected: - AudioAdc *audio_adc_; -}; - -} // namespace esphome::audio_adc diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 1351793afd4..fbaa72014fc 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -2,9 +2,8 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_VOLUME +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -13,10 +12,6 @@ IS_PLATFORM_COMPONENT = True audio_dac_ns = cg.esphome_ns.namespace("audio_dac") AudioDac = audio_dac_ns.class_("AudioDac") -MuteOffAction = audio_dac_ns.class_("MuteOffAction", automation.Action) -MuteOnAction = audio_dac_ns.class_("MuteOnAction", automation.Action) -SetVolumeAction = audio_dac_ns.class_("SetVolumeAction", automation.Action) - MUTE_ACTION_SCHEMA = maybe_simple_id( { @@ -33,41 +28,19 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( - "audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True -) -async def audio_dac_mute_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("audio_dac.mute_off", "set_mute_off()"), + ("audio_dac.mute_on", "set_mute_on()"), +): + automation.register_apply_action( + _name, MUTE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( +automation.register_apply_action( "audio_dac.set_volume", - SetVolumeAction, SET_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), ) -async def audio_dac_set_volume_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_VOLUME), args, cg.float_) - cg.add(var.set_volume(template_)) - - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h deleted file mode 100644 index 9c5348271c2..00000000000 --- a/esphome/components/audio_dac/automation.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "audio_dac.h" - -namespace esphome::audio_dac { - -template class MuteOffAction final : public Action { - public: - explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - void play(const Ts &...x) override { this->audio_dac_->set_mute_off(); } - - protected: - AudioDac *audio_dac_; -}; - -template class MuteOnAction final : public Action { - public: - explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - void play(const Ts &...x) override { this->audio_dac_->set_mute_on(); } - - protected: - AudioDac *audio_dac_; -}; - -template class SetVolumeAction final : public Action { - public: - explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - TEMPLATABLE_VALUE(float, volume) - - void play(const Ts &...x) override { this->audio_dac_->set_volume(this->volume_.value(x...)); } - - protected: - AudioDac *audio_dac_; -}; - -} // namespace esphome::audio_dac diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 5dfb1213429..a1104aa1b2a 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -203,16 +203,10 @@ void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &awa this->away_config_ = away_config; } -void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } - Trigger<> *BangBangClimate::get_idle_trigger() { return &this->idle_trigger_; } Trigger<> *BangBangClimate::get_cool_trigger() { return &this->cool_trigger_; } Trigger<> *BangBangClimate::get_heat_trigger() { return &this->heat_trigger_; } -void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void BangBangClimate::dump_config() { LOG_CLIMATE("", "Bang Bang Climate", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index d83257f9f34..fff9bf873ff 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -22,10 +22,10 @@ class BangBangClimate final : public climate::Climate, public Component { void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); - void set_supports_cool(bool supports_cool); - void set_supports_heat(bool supports_heat); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); void set_away_config(const BangBangClimateTargetTempConfig &away_config); diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 32707e8b0c8..b8de7932cd6 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput { void write_state(light::LightState *state) override { bool binary; state->current_values_as_binary(&binary); - if (binary) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(binary); } protected: diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f7..f5cde2c8e6f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,7 +1,7 @@ from logging import getLogger from esphome import automation, core -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE @@ -39,6 +39,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -60,14 +61,16 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -81,6 +84,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -132,15 +136,6 @@ MultiClickTriggerBase = binary_sensor_ns.class_( MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorPublishAction = binary_sensor_ns.class_( - "BinarySensorPublishAction", automation.Action -) -BinarySensorInvalidateAction = binary_sensor_ns.class_( - "BinarySensorInvalidateAction", automation.Action -) - -# Condition -BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Condition) # Filters Filter = binary_sensor_ns.class_("Filter") @@ -452,7 +447,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( @@ -637,6 +634,13 @@ async def new_binary_sensor(config, *args): return var +def sub_binary_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_binary_sensor.""" + return SubEntities(new_binary_sensor, config, parent) + + BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BinarySensor), @@ -644,20 +648,12 @@ BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( ) -@automation.register_condition( - "binary_sensor.is_on", BinarySensorCondition, BINARY_SENSOR_CONDITION_SCHEMA +automation.register_apply_condition( + "binary_sensor.is_on", BINARY_SENSOR_CONDITION_SCHEMA, "state" ) -async def binary_sensor_is_on_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition( - "binary_sensor.is_off", BinarySensorCondition, BINARY_SENSOR_CONDITION_SCHEMA +automation.register_apply_condition( + "binary_sensor.is_off", BINARY_SENSOR_CONDITION_SCHEMA, "state == false" ) -async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) @@ -665,20 +661,16 @@ async def to_code(config): cg.add_global(binary_sensor_ns.using) -@automation.register_action( +automation.register_apply_action( "binary_sensor.invalidate_state", - BinarySensorInvalidateAction, cv.maybe_simple_value( { cv.Required(CONF_ID): cv.use_id(BinarySensor), }, key=CONF_ID, ), - synchronous=True, + automation.ApplyCall("invalidate_state()"), ) -async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) # automation.cpp only implements the click/double_click/multi_click triggers diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index d5a85ca9c42..49457200bc6 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,26 +18,6 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger final : public Trigger<> { - public: - explicit PressTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { - if (state) - this->trigger(); - }); - } -}; - -class ReleaseTrigger final : public Trigger<> { - public: - explicit ReleaseTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { - if (!state) - this->trigger(); - }); - } -}; - bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); class ClickTrigger final : public Trigger<> { @@ -140,53 +120,4 @@ template class MultiClickTrigger final : public MultiClickTriggerBase std::array timing_storage_{}; }; -class StateTrigger final : public Trigger { - public: - explicit StateTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { this->trigger(state); }); - } -}; - -class StateChangeTrigger final : public Trigger, optional > { - public: - explicit StateChangeTrigger(BinarySensor *parent) { - parent->add_full_state_callback( - [this](optional old_state, optional state) { this->trigger(old_state, state); }); - } -}; - -template class BinarySensorCondition final : public Condition { - public: - BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - BinarySensor *parent_; - bool state_; -}; - -template class BinarySensorPublishAction final : public Action { - public: - explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto val = this->state_.value(x...); - this->sensor_->publish_state(val); - } - - protected: - BinarySensor *sensor_; -}; - -template class BinarySensorInvalidateAction final : public Action { - public: - explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} - - void play(const Ts &...x) override { this->sensor_->invalidate_state(); } - - protected: - BinarySensor *sensor_; -}; - } // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 28c156763a8..a96113b520e 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor() = default; + // User provided, not "= default": `new(p) BinarySensor()` would zero-fill .bss that is already zero. + explicit BinarySensor() {} const bool &get_state() const override { return this->state; } void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 6887de35e1d..1ec255d63d6 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -53,6 +53,9 @@ class DelayedOnOffFilter final : public Filter { class DelayedOnFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOnFilter()` would zero-fill .bss that is already zero. + DelayedOnFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } @@ -63,6 +66,9 @@ class DelayedOnFilter : public Filter { class DelayedOffFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOffFilter()` would zero-fill .bss that is already zero. + DelayedOffFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } @@ -143,6 +149,8 @@ class StatelessLambdaFilter : public Filter { class SettleFilter : public Filter { public: + // User provided, not "= default": `new(p) SettleFilter()` would zero-fill .bss that is already zero. + SettleFilter() {} optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb59548..38cba56c623 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 96b3536601b..55013d40876 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -53,7 +53,6 @@ BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( ) StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -121,9 +120,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "bk72xx_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -131,17 +129,8 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h index 9017d19d71e..f785b3eb3a1 100644 --- a/esphome/components/bk72xx_ble_tracker/automation.h +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -38,11 +38,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::bk72xx_ble_tracker #endif // USE_LIBRETINY diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 54de9f9b0cc..93ccf09521b 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -2,7 +2,6 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/datatypes.h" @@ -47,8 +46,6 @@ struct sbe24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-ali int8_t h{0}; } __attribute__((packed)); -template class ResetEnergyAction; - class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); @@ -85,9 +82,10 @@ class BL0906 final : public PollingComponent, public uart::UARTDevice { void setup() override; void dump_config() override; - protected: - template friend class ResetEnergyAction; + /// Queue an energy counter reset for the next poll + void reset_energy() { this->enqueue_action_(&BL0906::reset_energy_); } + protected: void reset_energy_(); void read_data_(uint8_t address, float reference, sensor::Sensor *sensor); @@ -103,9 +101,4 @@ class BL0906 final : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } -}; - } // namespace esphome::bl0906 diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 1a0c2287ab7..25c50555dbc 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,8 +32,6 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py @@ -45,7 +43,6 @@ CONF_TOTAL_ENERGY = "total_energy" bl0906_ns = cg.esphome_ns.namespace("bl0906") BL0906 = bl0906_ns.class_("BL0906", cg.PollingComponent, uart.UARTDevice) -ResetEnergyAction = bl0906_ns.class_("ResetEnergyAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -138,40 +135,25 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -@automation.register_action( +automation.register_apply_action( "bl0906.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BL0906), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) -async def reset_energy_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]) - return var async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_frequency_sensor(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) for i in range(6): if channel_config := config.get(f"{CONF_CHANNEL}_{i + 1}"): @@ -185,10 +167,5 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(energy_config) cg.add(getattr(var, f"set_energy_{i + 1}_sensor")(sens)) - if total_power_config := config.get(CONF_TOTAL_POWER): - sens = await sensor.new_sensor(total_power_config) - cg.add(var.set_total_power_sensor(sens)) - - if total_energy_config := config.get(CONF_TOTAL_ENERGY): - sens = await sensor.new_sensor(total_energy_config) - cg.add(var.set_total_energy_sensor(sens)) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_TOTAL_ENERGY, var.set_total_energy_sensor) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index ec17ef2c7ec..9f6bd7814e4 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -94,27 +94,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_1_config := config.get(CONF_CURRENT_1): - sens = await sensor.new_sensor(current_1_config) - cg.add(var.set_current_sensor_1(sens)) - if current_2_config := config.get(CONF_CURRENT_2): - sens = await sensor.new_sensor(current_2_config) - cg.add(var.set_current_sensor_2(sens)) - if active_power_1_config := config.get(CONF_ACTIVE_POWER_1): - sens = await sensor.new_sensor(active_power_1_config) - cg.add(var.set_power_sensor_1(sens)) - if active_power_2_config := config.get(CONF_ACTIVE_POWER_2): - sens = await sensor.new_sensor(active_power_2_config) - cg.add(var.set_power_sensor_2(sens)) - if energy_1_config := config.get(CONF_ENERGY_1): - sens = await sensor.new_sensor(energy_1_config) - cg.add(var.set_energy_sensor_1(sens)) - if energy_2_config := config.get(CONF_ENERGY_2): - sens = await sensor.new_sensor(energy_2_config) - cg.add(var.set_energy_sensor_2(sens)) - if energy_total_config := config.get(CONF_ENERGY_TOTAL): - sens = await sensor.new_sensor(energy_total_config) - cg.add(var.set_energy_sensor_sum(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT_1, var.set_current_sensor_1) + await sensors(CONF_CURRENT_2, var.set_current_sensor_2) + await sensors(CONF_ACTIVE_POWER_1, var.set_power_sensor_1) + await sensors(CONF_ACTIVE_POWER_2, var.set_power_sensor_2) + await sensors(CONF_ENERGY_1, var.set_energy_sensor_1) + await sensors(CONF_ENERGY_2, var.set_energy_sensor_2) + await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 7e6403c3bc9..1cf7e8e6e38 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -231,24 +231,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if internal_temperature_config := config.get(CONF_INTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(internal_temperature_config) - cg.add(var.set_internal_temperature_sensor(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) # enable legacy mode cg.add(var.set_legacy_mode(config.get(CONF_LEGACY_MODE))) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 5531fe411ba..397471cf7a9 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -101,21 +101,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(var.set_reset(config[CONF_RESET])) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 1ef7967fa83..810f4b66e3d 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -207,26 +207,20 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "ble_client.disconnect", BLEDisconnectAction, BLE_CONNECT_ACTION_SCHEMA, synchronous=False, ) -async def ble_disconnect_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) -@automation.register_action( +automation.register_simple_action( "ble_client.connect", BLEConnectAction, BLE_CONNECT_ACTION_SCHEMA, synchronous=False, ) -async def ble_connect_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -328,15 +322,12 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "ble_client.remove_bond", BLERemoveBondAction, BLE_REMOVE_BOND_ACTION_SCHEMA, synchronous=True, ) -async def remove_bond_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 94eeb83b3eb..93aae23b6a1 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -22,6 +22,23 @@ class Automation { static const char *const TAG; }; +// Base for nodes that never read the parent's services. +// The parent releases its services only once every node reports Established, so a node that never +// reports it keeps that memory allocated for the life of the connection. +class BLEClientServicelessNode : public BLEClientNode { + public: + // Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event. + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final { + if (event == ESP_GATTC_SEARCH_CMPL_EVT) + this->node_state = espbt::ClientState::ESTABLISHED; + this->on_gattc_event(event, gattc_if, param); + } + + protected: + // Derived nodes handle GATT events here rather than by overriding the handler above. + virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {} +}; + // implement on_connect automation. class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: @@ -61,7 +78,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode } }; -class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +88,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientN } }; -class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +99,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger, publ } }; -class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -315,19 +332,17 @@ template class BLEClientRemoveBondAction final : public Action class BLEClientConnectAction final : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { case ESP_GATTC_SEARCH_CMPL_EVT: - this->node_state = espbt::ClientState::ESTABLISHED; this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); }); break; // if the connection is closed, terminate the automation chain. @@ -364,14 +379,13 @@ template class BLEClientConnectAction final : public Action var_{}; }; -template class BLEClientDisconnectAction final : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index c0837da4025..40983c4dbb0 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -7,6 +7,7 @@ from esphome.components.uart import ( uart_ns, ) from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_DEBUG, @@ -61,6 +62,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT_NUS", True) + zephyr_add_prj_conf("RING_BUFFER", True) + request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU expose_log = config[CONF_TYPE] == CONF_LOGS cg.add(var.set_expose_log(expose_log)) if expose_log: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 15f854239d4..986a67c7a8f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -45,15 +45,7 @@ void BluedroidGattClient::setup() { void BluedroidGattClient::loop() { if (!esp32_ble::global_ble->is_active()) { - // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer - // frees its slot, then re-register the app on the next enable. - auto down_st = this->state(); - if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { - this->release_services(); - this->set_idle_(); - this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); - } - this->set_state(ClientState::INIT); + // ble_before_disabled_event_handler() settles the slot. return; } auto st = this->state(); @@ -65,7 +57,7 @@ void BluedroidGattClient::loop() { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } - // Do not wait for REG_EVT; a dropped event must not wedge the slot. + // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled @@ -78,8 +70,8 @@ void BluedroidGattClient::loop() { this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); } } else { - // The loop stays on while a link exists (stack-down watch, pre-started - // search flush); it settles only back at IDLE. + // The loop stays on while a link exists (pre-started search flush); it + // settles only back at IDLE. this->deliver_pending_search_(); if (this->state() == ClientState::IDLE) { this->disable_loop(); @@ -87,6 +79,22 @@ void BluedroidGattClient::loop() { } } +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); if (this->is_failed()) { @@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } // Only from idle: clobbering DISCONNECTING would open a new link the // stale CLOSE_EVT then tears down. if (this->state() != ClientState::IDLE) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0d0b4fed5b6..a4e9edec233 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -31,6 +31,9 @@ class BluetoothConnection; // void disconnect() cannot overload with an int-returning twin. class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { public: + // User provided, not "= default": `new(p) BluedroidGattClient()` would zero-fill .bss that is already zero. + BluedroidGattClient() {} + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; // Lifecycle of one connection attempt's service search. @@ -56,6 +59,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void connect() override; void disconnect() override; + void ble_before_disabled_event_handler() override; bool wants_parsed_advertisements() override { return false; } void on_scan_end() override {} bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 47181e81a73..4c87b876c3a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -37,6 +37,9 @@ enum class PendingAck : uint8_t { class BluetoothConnection final : public ble_device_base::GattClientListener { public: + // User provided, not "= default": `new(p) BluetoothConnection()` would zero-fill .bss that is already zero. + BluetoothConnection() {} + /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { this->backend_ = backend; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 16a89dcfdd1..eec2c8c3186 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { // explicit kick the MTU would only be exchanged on the first GATT query, // which never happens on a V3_WITH_CACHE connection. // Both registration calls above return void (BTstack 075a078, arduino-pico - // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by // the connect timeout in loop(). gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); } diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 1b761849a52..c87ad7f5957 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -395,6 +395,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await _connections_to_code(var, config) +def enable_advertisement_filter() -> None: + """Compile the advertisement filter hook into bluetooth_proxy. + + Called by external filtering components from to_code(). The define behind + this is an implementation detail; do not emit it directly. + + Public API for external components. Do not remove. + """ + cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER") + + async def to_code(config: ConfigType) -> None: if CORE.is_esp32: await _to_code_esp32(config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 878d3cd44e9..cb37057cd4b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + // Ask the filter before the packet is queued, so a dropped advertisement never + // reaches the batch or the network. + if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) { + ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address); + return; + } +#endif + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; adv.address = raw.address; adv.rssi = raw.rssi; @@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set())); +#endif } #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e233c38b567..567109dc60b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556 static_assert(PendingReply{}.empty()); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER +/// Predicate slot letting an external component drop advertisements before they +/// are queued for the API. Same shape as +/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot +/// path, so it must be cheap and must not block. +/// +/// Usage: +/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { +/// return static_cast(self)->should_forward(adv); +/// }}); +/// +/// Returning false drops the advertisement. Not called at all while the API is +/// disconnected, which matters to a stateful filter. Compiled in only when an +/// external component calls bluetooth_proxy.enable_advertisement_filter(). +struct AdvertisementFilter { + void *instance{nullptr}; + bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no filter"; the proxy guards on this. + bool is_set() const { return this->fn != nullptr; } + bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); } +}; +#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + class BluetoothProxy final : public Component { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ @@ -162,6 +185,11 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + /// One subscriber; a later call replaces an earlier one. + void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; } +#endif + uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -330,6 +358,10 @@ class BluetoothProxy final : public Component { // start on an even word, closing two alignment holes. uint32_t last_advertisement_flush_time_{0}; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + AdvertisementFilter advertisement_filter_{}; +#endif + // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index 5ca9714091d..b9b7a2c3276 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -34,24 +34,4 @@ class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; - -template class TimerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint32_t, duration) - - void play(const Ts &...x) override { - auto duration = this->duration_.value(x...); - this->parent_->start_timer(duration); - } -}; - } // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 5ef162bb7c4..c28976eb872 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -13,9 +11,6 @@ I2C_ADDR = 0x51 bm8563_ns = cg.esphome_ns.namespace("bm8563") BM8563 = bm8563_ns.class_("BM8563", time.RealTimeClock, i2c.I2CDevice) -WriteAction = bm8563_ns.class_("WriteAction", automation.Action) -ReadAction = bm8563_ns.class_("ReadAction", automation.Action) -TimerAction = bm8563_ns.class_("TimerAction", automation.Action) CONFIG_SCHEMA = ( time.TIME_SCHEMA.extend( @@ -28,70 +23,30 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( - "bm8563.write_time", - WriteAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(BM8563), - } - ), - synchronous=True, +BM8563_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } ) -async def bm8563_write_time_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]) - return var +automation.register_apply_action( + "bm8563.write_time", BM8563_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) -@automation.register_action( +automation.register_apply_action( + "bm8563.read_time", BM8563_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + +automation.register_apply_action( "bm8563.start_timer", - TimerAction, cv.Schema( { cv.GenerateID(): cv.use_id(BM8563), cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), } ), - synchronous=True, + automation.ApplyField(CONF_DURATION, "start_timer", cg.uint32), ) -async def bm8563_start_timer_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]) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(template_)) - return var - - -@automation.register_action( - "bm8563.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(BM8563), - } - ), - synchronous=True, -) -async def bm8563_read_time_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]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 287946801eb..4b079def64d 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -90,20 +90,14 @@ async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING])) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING])) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING] + cg.add(var.set_temperature_oversampling(oversampling)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING])) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING])) cg.add(var.set_iir_filter(config[CONF_IIR_FILTER])) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 0f7e42cce3e..11c796352a3 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -341,7 +341,6 @@ void BME280Component::set_pressure_oversampling(BME280Oversampling pressure_over void BME280Component::set_humidity_oversampling(BME280Oversampling humidity_over_sampling) { this->humidity_oversampling_ = humidity_over_sampling; } -void BME280Component::set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BME280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->read_byte(a_register, &data); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 7fe5f7401da..8b4906b7b77 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -69,7 +69,7 @@ class BME280Component : public PollingComponent { /// Set the oversampling value for the humidity sensor. Default is 16x. void set_humidity_oversampling(BME280Oversampling humidity_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BME280IIRFilter iir_filter); + void set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 164424de096..bac8ed8a5a8 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -503,7 +503,6 @@ void BME680Component::set_pressure_oversampling(BME680Oversampling pressure_over void BME680Component::set_humidity_oversampling(BME680Oversampling humidity_oversampling) { this->humidity_oversampling_ = humidity_oversampling; } -void BME680Component::set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void BME680Component::set_heater(uint16_t heater_temperature, uint16_t heater_duration) { this->heater_temperature_ = heater_temperature; this->heater_duration_ = heater_duration; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index a274578fc18..e401d036595 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -74,7 +74,7 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { /// Set the humidity oversampling value. Defaults to 16X. void set_humidity_oversampling(BME680Oversampling humidity_oversampling); /// Set the IIR Filter value. Defaults to no IIR Filter. - void set_iir_filter(BME680IIRFilter iir_filter); + void set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index dce5c88cfaa..ffe5c74bbd4 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -131,24 +131,15 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING])) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING])) - - if gas_resistance_config := config.get(CONF_GAS_RESISTANCE): - sens = await sensor.new_sensor(gas_resistance_config) - cg.add(var.set_gas_resistance_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING] + cg.add(var.set_temperature_oversampling(oversampling)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING])) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING])) + await sensors(CONF_GAS_RESISTANCE, var.set_gas_resistance_sensor) cg.add(var.set_iir_filter(IIR_FILTER_OPTIONS[config[CONF_IIR_FILTER]])) diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 1dae5a689e6..34e1d671019 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -254,7 +254,6 @@ void BMP280Component::set_temperature_oversampling(BMP280Oversampling temperatur void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over_sampling) { this->pressure_oversampling_ = pressure_over_sampling; } -void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->bmp_read_byte(a_register, &data); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 3bf1edab043..860fff6b4b1 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -59,7 +59,7 @@ class BMP280Component : public PollingComponent { /// Set the oversampling value for the pressure sensor. Default is 16x. void set_pressure_oversampling(BMP280Oversampling pressure_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BMP280IIRFilter iir_filter); + void set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void setup() override; void dump_config() override; diff --git a/esphome/components/bridge/__init__.py b/esphome/components/bridge/__init__.py new file mode 100644 index 00000000000..49811b01819 --- /dev/null +++ b/esphome/components/bridge/__init__.py @@ -0,0 +1,4 @@ +CODEOWNERS = ["@kbx81"] +DOMAIN = "bridge" + +IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index f559d0aa9b1..86ff34a3492 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -72,18 +72,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) - if temp_sens := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temp_sens) - cg.add(var.set_temperature(sens)) - if humi_sens := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humi_sens) - cg.add(var.set_humidity(sens)) - if batl_sens := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(batl_sens) - cg.add(var.set_battery_level(sens)) - if batv_sens := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(batv_sens) - cg.add(var.set_battery_voltage(sens)) - if sgnl_sens := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(sgnl_sens) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index ee24002b8a3..342f4515093 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,12 +18,13 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] @@ -40,8 +41,6 @@ button_ns = cg.esphome_ns.namespace("button") Button = button_ns.class_("Button", cg.EntityBase) ButtonPtr = Button.operator("ptr") -PressAction = button_ns.class_("PressAction", automation.Action) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -116,6 +115,13 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: return var +def sub_buttons( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_button.""" + return SubEntities(new_button, config, parent) + + BUTTON_PRESS_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Button), @@ -123,17 +129,9 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True +automation.register_apply_action( + "button.press", BUTTON_PRESS_SCHEMA, automation.ApplyCall("press()") ) -async def button_press_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h deleted file mode 100644 index d55d43ea370..00000000000 --- a/esphome/components/button/automation.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/components/button/button.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::button { - -template class PressAction final : public Action { - public: - explicit PressAction(Button *button) : button_(button) {} - - void play(const Ts &...x) override { this->button_->press(); } - - protected: - Button *button_; -}; - -class ButtonPressTrigger final : public Trigger<> { - public: - ButtonPressTrigger(Button *button) { - button->add_on_press_callback([this]() { this->trigger(); }); - } -}; - -} // namespace esphome::button diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 01e3ed0cd54..5de39a16cec 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -351,10 +351,6 @@ async def to_code(config): # Actions -BeginTxAction = ns.class_("BeginTxAction", automation.Action) -BeginRxAction = ns.class_("BeginRxAction", automation.Action) -ResetAction = ns.class_("ResetAction", automation.Action) -SetIdleAction = ns.class_("SetIdleAction", automation.Action) SendPacketAction = ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) ) @@ -364,22 +360,15 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA, synchronous=True -) -async def cc1101_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("cc1101.begin_tx", "begin_tx()"), + ("cc1101.begin_rx", "begin_rx()"), + ("cc1101.reset", "reset()"), + ("cc1101.set_idle", "set_idle()"), +): + automation.register_apply_action( + _name, CC1101_ACTION_SCHEMA, automation.ApplyCall(_call) + ) def validate_raw_data(value): @@ -424,91 +413,41 @@ async def send_packet_action_to_code(config, action_id, template_arg, args): return var -# Setter action definitions: (setter_name, validator, template_type, enum_map) -_SETTER_ACTIONS = [ - ( - "set_frequency", - cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)), - float, - None, - ), - ("set_output_power", cv.float_range(min=-30.0, max=11.0), float, None), - ("set_modulation_type", cv.enum(MODULATION, upper=False), Modulation, MODULATION), - ("set_symbol_rate", cv.float_range(min=600, max=500000), float, None), - ( - "set_rx_attenuation", - cv.enum(RX_ATTENUATION, upper=False), - RxAttenuation, - RX_ATTENUATION, - ), - ("set_dc_blocking_filter", cv.boolean, bool, None), - ("set_manchester", cv.boolean, bool, None), - ( - "set_filter_bandwidth", - cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), - float, - None, - ), - ( - "set_fsk_deviation", - cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), - float, - None, - ), - ("set_msk_deviation", cv.int_range(min=1, max=8), cg.uint8, None), - ("set_channel", cv.uint8_t, cg.uint8, None), - ( - "set_channel_spacing", - cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), - float, - None, - ), - ( - "set_if_frequency", - cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), - float, - None, - ), -] +_CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()} + +# Each key has a set_ action validated exactly like the config option. +_SETTER_ACTIONS = ( + (CONF_FREQUENCY, cg.float_), + (CONF_OUTPUT_POWER, cg.float_), + (CONF_MODULATION_TYPE, Modulation), + (CONF_SYMBOL_RATE, cg.float_), + (CONF_RX_ATTENUATION, RxAttenuation), + (CONF_DC_BLOCKING_FILTER, cg.bool_), + (CONF_MANCHESTER, cg.bool_), + (CONF_FILTER_BANDWIDTH, cg.float_), + (CONF_FSK_DEVIATION, cg.float_), + (CONF_MSK_DEVIATION, cg.uint8), + (CONF_CHANNEL, cg.uint8), + (CONF_CHANNEL_SPACING, cg.float_), + (CONF_IF_FREQUENCY, cg.float_), +) -def _register_setter_actions(): - for setter_name, validator, templ_type, enum_map in _SETTER_ACTIONS: - class_name = ( - "".join(word.capitalize() for word in setter_name.split("_")) + "Action" +def _register_setter_actions() -> None: + for conf_key, value_type in _SETTER_ACTIONS: + automation.register_apply_action( + f"cc1101.set_{conf_key}", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_VALUE): cv.templatable( + _CONFIG_VALIDATORS[conf_key] + ), + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, f"set_{conf_key}", value_type), ) - action_cls = ns.class_( - class_name, automation.Action, cg.Parented.template(CC1101Component) - ) - schema = cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_VALUE): cv.templatable(validator), - }, - key=CONF_VALUE, - ) - - async def _setter_action_to_code( - config, - action_id, - template_arg, - args, - _setter=setter_name, - _type=templ_type, - _map=enum_map, - ): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_VALUE] - if _map and not cg.is_template(data): - data = _map[data] - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - return var - - automation.register_action( - f"cc1101.{setter_name}", action_cls, schema, synchronous=True - )(_setter_action_to_code) _register_setter_actions() diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 79bfc9cb33e..499f77a809c 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -130,26 +130,6 @@ class CC1101Component final : public Component, }; // Action Wrappers -template class BeginTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_tx(); } -}; - -template class BeginRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_rx(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetIdleAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_idle(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } @@ -174,83 +154,4 @@ template class SendPacketAction final : public Action, pu size_t data_static_len_{0}; }; -template class SetSymbolRateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, symbol_rate) - void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } -}; - -template class SetFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, frequency) - void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } -}; - -template class SetOutputPowerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, output_power) - void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } -}; - -template class SetModulationTypeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Modulation, modulation_type) - void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } -}; - -template class SetRxAttenuationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) - void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } -}; - -template -class SetDcBlockingFilterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, dc_blocking_filter) - void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } -}; - -template class SetManchesterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, manchester) - void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } -}; - -template class SetFilterBandwidthAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, filter_bandwidth) - void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } -}; - -template class SetFskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, fsk_deviation) - void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } -}; - -template class SetMskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, msk_deviation) - void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } -}; - -template class SetChannelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } -}; - -template class SetChannelSpacingAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, channel_spacing) - void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } -}; - -template class SetIfFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, if_frequency) - void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } -}; - } // namespace esphome::cc1101 diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index d134d2cf21f..95e51c825a8 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -65,17 +65,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_co2(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc(sens)) - - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(var.set_version(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, var.set_version) if (baseline := config.get(CONF_BASELINE)) is not None: cg.add(var.set_baseline(baseline)) diff --git a/esphome/components/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..516af848564 --- /dev/null +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@kbx81"] diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py new file mode 100644 index 00000000000..cee048df5da --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -0,0 +1,114 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, uart, usb_cdc_acm +from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"] + +CONF_DTR_PIN = "dtr_pin" +CONF_RTS_PIN = "rts_pin" +CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id" + +cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart") +CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CDCACMUARTBridge), + cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent), + cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance), + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + } + ).extend(cv.COMPONENT_SCHEMA), + # Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on + # hardware; extend once verified. + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + """Return True if any dict in the subtree has a uart_id entry naming this bus.""" + if isinstance(node, dict): + return any( + (key == CONF_UART_ID and str(value) == uart_id) + or _subtree_references_uart(value, uart_id) + for key, value in node.items() + ) + if isinstance(node, list): + return any(_subtree_references_uart(item, uart_id) for item in node) + return False + + +def _reject_debug(uart_conf: ConfigType) -> ConfigType: + # The worker tasks use the IDF driver directly, so the uart debugger never sees + # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. + if CONF_DEBUG in uart_conf: + raise cv.Invalid( + "A bridged UART cannot use 'debug'; the bridge bypasses the UART " + "component's read/write path.", + [CONF_DEBUG], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + # Bridges of any platform must own their interfaces exclusively; shared ring + # buffers and overwritten callbacks would corrupt both streams silently. The + # seen-set is keyed on the bridge domain so future platforms share it. + # Other components bind either interface through the same uart_id key (the CDC + # instance is itself a uart::UARTComponent) and would race the worker tasks. + # Bare `id:` references (a uart.write action) cannot be distinguished; not caught. + data = full_config.data.setdefault(BRIDGE_DOMAIN, {}) + for conf_key, label in ( + (CONF_UART_ID, "UART"), + (CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"), + ): + owned_id = str(config[conf_key]) + used = data.setdefault(conf_key, set()) + if owned_id in used: + raise cv.Invalid( + f"The {label} '{owned_id}' is already bridged by another 'bridge' " + f"instance; each bridge requires its own {label}.", + [conf_key], + ) + used.add(owned_id) + for domain, domain_conf in full_config.items(): + if domain == BRIDGE_DOMAIN: + continue + if _subtree_references_uart(domain_conf, owned_id): + raise cv.Invalid( + f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " + f"requires exclusive use of its {label}.", + [conf_key], + ) + + fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID]) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + uart_component = await cg.get_variable(config[CONF_UART_ID]) + usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID]) + var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) + await cg.register_component(var, config) + + if dtr_pin_config := config.get(CONF_DTR_PIN): + dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) + cg.add(var.set_dtr_pin(dtr_pin)) + if rts_pin_config := config.get(CONF_RTS_PIN): + rts_pin = await cg.gpio_pin_expression(rts_pin_config) + cg.add(var.set_rts_pin(rts_pin)) diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp new file mode 100644 index 00000000000..042688bfe61 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -0,0 +1,468 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "cdc_acm_uart_bridge.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "driver/uart.h" +#include "soc/soc_caps.h" + +namespace esphome::cdc_acm_uart { + +static const char *const TAG = "cdc_acm_uart"; + +static constexpr size_t UART_TASK_STACK_SIZE = 4096; +static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20; +// Above the default priority but below the USB/Wi-Fi system tasks. +static constexpr UBaseType_t TASK_PRIORITY = 4; + +static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) { + uint32_t now = millis(); + if ((now - *last_ms) >= interval_ms) { + *last_ms = now; + return true; + } + return false; +} + +static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) { + if (len == 0) { + return true; + } + + if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) { + return true; + } + + size_t offset = 0; + while (offset < len) { + size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset); + if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) { + if (should_log_now(log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "USB TX buffer full; some data is lost"); + } + return false; + } + offset += chunk; + } + return true; +} + +void CDCACMUARTBridge::setup() { + // Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is + // handled by configuring the pins inverted, so deasserted idles HIGH. + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(false); + } + + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(false); + } + + // A failed UART never assigned its port number, so the worker tasks would run + // against an indeterminate port. + if (this->uart_parent_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->configured_baud_rate_ = this->uart_parent_->get_baud_rate(); + this->configured_parity_ = this->uart_parent_->get_parity(); + this->configured_stop_bits_ = this->uart_parent_->get_stop_bits(); + this->configured_data_bits_ = this->uart_parent_->get_data_bits(); + + // usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks + // the hub failed, and a failed hub no longer runs loop(), so line coding and line + // state events would never reach this bridge even if its own interface is healthy. + if (this->usb_cdc_parent_->get_parent()->is_failed()) { + ESP_LOGE(TAG, "USB CDC ACM failed; aborting"); + this->mark_failed(); + return; + } + + // Per-instance task names (keyed on the CDC interface number) keep task dumps + // unambiguous with multiple bridges. + char tx_task_name[] = "cdc_uart_tx_0"; + char rx_task_name[] = "cdc_uart_rx_0"; + const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf()); + tx_task_name[sizeof(tx_task_name) - 2] = itf_char; + rx_task_name[sizeof(rx_task_name) - 2] = itf_char; + + xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_); + if (this->uart_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART TX task"); + this->mark_failed(); + return; + } + + xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_); + if (this->uart_rx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART RX task"); + vTaskDelete(this->uart_tx_task_handle_); + this->uart_tx_task_handle_ = nullptr; + this->mark_failed(); + return; + } + + // Only register callbacks once both tasks exist, so a failed setup never drives + // DTR/RTS from a dead bridge. + this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); }); + this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) { + this->host_coding_seen_ = true; + // Another component owns the UART's framing while paused; resume() re-syncs. + if (this->paused_ == 0) { + this->set_line_coding(); + } + }); + + // Release the workers only now: until here a failed setup may still delete the TX + // task, which is safe only while it is parked and owns nothing in the driver. + xTaskNotifyGive(this->uart_tx_task_handle_); + xTaskNotifyGive(this->uart_rx_task_handle_); + + // loop() only services line-coding reloads; stay off the main loop until one is + // scheduled. + this->disable_loop(); +} + +void CDCACMUARTBridge::dump_config() { + ESP_LOGCONFIG(TAG, + "CDC-ACM UART Bridge:\n" + " UART Bus: %u\n" + " USB CDC Interface: %u", + this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf()); + LOG_PIN(" DTR Pin: ", this->dtr_pin_); + LOG_PIN(" RTS Pin: ", this->rts_pin_); +} + +void CDCACMUARTBridge::on_shutdown() { + // The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver, + // freeing the ring buffer and mutexes the worker tasks block on. Suspending the + // tasks unlinks them from those objects first. + if (this->uart_rx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_rx_task_handle_); + } + if (this->uart_tx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_tx_task_handle_); + } +} + +void CDCACMUARTBridge::loop() { + switch (this->state_) { + case MainState::MAIN_STATE_RELOAD_PENDING: + if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) { + return; + } + // Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream + // wants the new framing now, and its own in-flight bytes are its concern. + // apply_settings_live() rewrites the framing registers without reinstalling the + // driver, so the worker tasks blocked inside it are undisturbed. + this->uart_parent_->apply_settings_live(); + this->state_ = MainState::MAIN_STATE_RUNNING; + break; + case MainState::MAIN_STATE_PAUSING: + case MainState::MAIN_STATE_RESUMING: + // Let a host write that was in flight drain, FIFO included, before a reload + // flushes the FIFOs and truncates it. + if (!this->tx_idle_()) { + return; + } + if (this->state_ == MainState::MAIN_STATE_PAUSING) { + this->restore_configured_framing_(); + this->state_ = MainState::MAIN_STATE_PAUSED; + } else { + this->finish_resume_(); + } + break; + default: + break; + } + this->disable_loop(); +} + +void CDCACMUARTBridge::set_line_coding() { + if (!this->sync_host_framing_()) { + return; + } + // Coalesce rapid line-coding updates from the host. + this->reload_requested_at_ = App.get_loop_component_start_time(); + this->state_ = MainState::MAIN_STATE_RELOAD_PENDING; + // Main-loop context (via USBCDCACMInstance::process_events_). + this->enable_loop(); +} + +bool CDCACMUARTBridge::sync_host_framing_() { + // usb_cdc_acm has already translated the wire coding onto the CDC instance (main + // loop); mirror it here so the framing translation has a single source of truth. + bool changed = false; + + // Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate) + // and rates above the SoC ceiling. Anything in between is the driver's call, + // matching what a YAML-configured UART accepts. + const uint32_t baud = this->usb_cdc_parent_->get_baud_rate(); + if (baud == 0 || baud > SOC_UART_BITRATE_MAX) { + ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud, + this->uart_parent_->get_baud_rate()); + } else if (this->uart_parent_->get_baud_rate() != baud) { + this->uart_parent_->set_baud_rate(baud); + changed = true; + } + + const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits(); + if (this->uart_parent_->get_stop_bits() != stop_bits) { + this->uart_parent_->set_stop_bits(stop_bits); + changed = true; + } + + const auto parity = this->usb_cdc_parent_->get_parity(); + if (this->uart_parent_->get_parity() != parity) { + this->uart_parent_->set_parity(parity); + changed = true; + } + + // USB CDC permits data-bit counts the UART cannot represent (up to 16). + const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits(); + if (data_bits < 5 || data_bits > 8) { + ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits, + this->uart_parent_->get_data_bits()); + } else if (this->uart_parent_->get_data_bits() != data_bits) { + this->uart_parent_->set_data_bits(data_bits); + changed = true; + } + + if (changed) { + ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u", + this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(), + this->uart_parent_->get_stop_bits(), static_cast(this->uart_parent_->get_parity())); + } + return changed; +} + +void CDCACMUARTBridge::pause() { + if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) { + return; + } + this->paused_ = 1; + // A null RX task means setup() has not completed (or failed): nothing to stop, and + // the framing snapshot does not exist yet. Should setup() run later, the RX task + // starts parked. + if (this->uart_rx_task_handle_ == nullptr) { + this->state_ = MainState::MAIN_STATE_PAUSED; + return; + } + // Drops a coalesced host reload or a pending resume; loop() restores the framing + // once any host write in flight has drained. + this->state_ = MainState::MAIN_STATE_PAUSING; + this->enable_loop(); +} + +void CDCACMUARTBridge::resume() { + if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) { + return; + } + if (this->uart_rx_task_handle_ == nullptr) { + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + return; + } + // A restore still waiting on the TX side is moot: the host's framing is kept. + if (!this->tx_idle_()) { + this->state_ = MainState::MAIN_STATE_RESUMING; + this->enable_loop(); + return; + } + this->finish_resume_(); + this->disable_loop(); +} + +void CDCACMUARTBridge::finish_resume_() { + // Take the bus back at a known framing before either task runs again: the host's + // if it ever sent one, else the YAML framing (the other owner may have changed it). + if (this->host_coding_seen_) { + this->sync_host_framing_(); + this->uart_parent_->apply_settings_live(); + } else { + this->restore_configured_framing_(); + } + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + this->drive_line_state_(); + xTaskNotifyGive(this->uart_rx_task_handle_); +} + +bool CDCACMUARTBridge::tx_idle_() { + const auto uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK; +} + +void CDCACMUARTBridge::restore_configured_framing_() { + // Always applied: the cached settings can lead the hardware by a pending reload, + // so they are no proof of what is live. + this->uart_parent_->set_baud_rate(this->configured_baud_rate_); + this->uart_parent_->set_parity(this->configured_parity_); + this->uart_parent_->set_stop_bits(this->configured_stop_bits_); + this->uart_parent_->set_data_bits(this->configured_data_bits_); + this->uart_parent_->apply_settings_live(); +} + +void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) { + ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts); + this->host_dtr_ = dtr; + this->host_rts_ = rts; + // Frozen while paused: a host opening the port must not reset a peer that another + // component is talking to. + if (this->paused_ == 0) { + this->drive_line_state_(); + } +} + +void CDCACMUARTBridge::drive_line_state_() { + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->digital_write(this->host_dtr_); + } + if (this->rts_pin_ != nullptr) { + this->rts_pin_->digital_write(this->host_rts_); + } +} + +void CDCACMUARTBridge::uart_rx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_rx_task_(); +} + +void CDCACMUARTBridge::uart_tx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_tx_task_(); +} + +void CDCACMUARTBridge::uart_rx_task_() { + TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle(); + RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + + uint8_t *data = this->uart_rx_buffer_.data(); + const size_t buf_size = this->uart_rx_buffer_.size(); + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + if (this->paused_ != 0) { + // Parked until resume() notifies; nothing is read, so the other owner sees + // every byte. + this->rx_parked_ = 1; + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + this->rx_parked_ = 0; + continue; + } + + // Block until at least one byte is available from UART. + int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS)); + if (total_rx_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", total_rx_size); + } + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + if (total_rx_size == 0) { + continue; + } + // pause() landed during the read: don't forward a byte to a host that is gone. + if (this->paused_ != 0) { + continue; + } + + // Drain the currently buffered burst without waiting. + while (true) { + int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0); + if (rx_data_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", rx_data_size); + } + break; + } + if (rx_data_size == 0) { + break; + } + ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size); + total_rx_size += rx_data_size; + if (total_rx_size >= (int) buf_size) { + break; + } + } + + ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms); + + ESP_LOGV(TAG, "UART RX: waking up USB TX task"); + xTaskNotifyGive(usb_tx_handle); + } +} + +void CDCACMUARTBridge::uart_tx_task_() { + RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + uint8_t *data_to_uart = this->uart_tx_buffer_.data(); + const size_t buf_size = this->uart_tx_buffer_.size(); + size_t rx_size; + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS; + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + ESP_LOGV(TAG, "Waiting for data to send to UART"); + esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY); + + if (ret != ESP_OK) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "USB RX RingBuf read failed"); + } + // Yield: this task runs above the main loop, so a persistent failure must not + // become a tight loop. + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + // Another component owns the UART; host bytes must not interleave with its traffic. + // tx_busy_ goes up before the check so is_paused() cannot miss a write in flight. + this->tx_busy_ = 1; + if (this->paused_ != 0) { + this->tx_busy_ = 0; + if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size); + } + continue; + } + + ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size); + // Signed: uart_write_bytes() returns -1 on error. + int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size); + this->tx_busy_ = 0; + + if (xfer_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART write failed: %d", xfer_size); + } + } else if (static_cast(xfer_size) != rx_size) { + ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size); + } + } +} + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h new file mode 100644 index 00000000000..405b794653d --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -0,0 +1,118 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h" +#include "esphome/core/component.h" + +#include +#include +#include "sdkconfig.h" + +namespace esphome::cdc_acm_uart { + +class CDCACMUARTBridge final : public Component { + public: + // Upper bound on the RX task's blocking read, so pause() takes effect without + // aborting the read. Arriving bytes still unblock it immediately. + static constexpr uint32_t UART_RX_WAIT_MS = 250; + + CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent) + : uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {} + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; } + void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; } + + void set_line_coding(); + void set_line_state(bool dtr, bool rts); + uart::IDFUARTComponent *get_uart_parent() const { return this->uart_parent_; } + + /** + * Stop forwarding in both directions and hand the UART back to its configured + * framing, so another component may use the bus. Main-loop only. The RX task parks + * within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write + * already in flight is allowed to drain first, which at low baud rates can take + * seconds; the framing is restored only after that, so poll is_paused() rather than + * waiting a fixed interval. Host bytes not yet written to the UART are discarded. + * The DTR/RTS outputs hold their state while paused and follow the host again on + * resume(). + */ + void pause(); + /** + * Re-apply the host's line coding and line state, then resume forwarding. Main-loop + * only. Deferred until any host write still draining has finished, so the reload + * never truncates it. + */ + void resume(); + /// True once both worker tasks are off the bus and the configured framing is restored. + /// With no RX task (setup() failed or has not run) there is nothing to wait for. + bool is_paused() const { + return this->state_ == MainState::MAIN_STATE_PAUSED && + (this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0); + } + + protected: + static void uart_rx_task_fn(void *arg); + static void uart_tx_task_fn(void *arg); + void uart_rx_task_(); + void uart_tx_task_(); + void restore_configured_framing_(); + // True when the TX task has no write in flight and the UART TX FIFO has drained. + bool tx_idle_(); + void finish_resume_(); + void drive_line_state_(); + // Copy the host's line coding onto the UART settings; true if anything changed. + bool sync_host_framing_(); + + TaskHandle_t uart_rx_task_handle_{nullptr}; + TaskHandle_t uart_tx_task_handle_{nullptr}; + + GPIOPin *dtr_pin_{nullptr}; + GPIOPin *rts_pin_{nullptr}; + + uint32_t reload_requested_at_{0}; + + // Worker staging, each sized to the CDC ring buffer it feeds or drains. + std::array uart_rx_buffer_{}; + std::array uart_tx_buffer_{}; + + uart::IDFUARTComponent *uart_parent_; + usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_; + + // YAML framing, captured at setup; the host's line coding overwrites the UART's + // settings, so pause() needs the original to restore. + uint32_t configured_baud_rate_{0}; + uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE}; + uint8_t configured_stop_bits_{0}; + uint8_t configured_data_bits_{0}; + + // Written on the main loop, read by both worker tasks. uint8_t rather than bool: + // GCC on Xtensa emits an out-of-line call for atomic. + std::atomic paused_{0}; + // Raised by the RX task while parked and by the TX task around each UART write, so + // the pause hand-off knows when the bus is actually free. + std::atomic rx_parked_{0}; + std::atomic tx_busy_{0}; + // Main-loop state; paused_ mirrors it for the worker tasks. + enum class MainState : uint8_t { + MAIN_STATE_RUNNING, + MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues + MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing + MAIN_STATE_PAUSED, + MAIN_STATE_RESUMING, // resume() requested while a host write still drains + }; + MainState state_{MainState::MAIN_STATE_RUNNING}; + // Host line state, recorded even while paused so resume() can re-drive the pins. + bool host_dtr_{false}; + bool host_rts_{false}; + // True once the host has sent any line coding; resume() then re-syncs to it. + bool host_coding_seen_{false}; +}; + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 3fbca1a6d03..e64092d30e5 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -50,18 +50,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import ( - LambdaExpression, - MockObj, - MockObjClass, - TemplateArgsType, -) +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -169,7 +164,6 @@ def visual_temperature_step(value: Any) -> ConfigType: # Actions -ControlAction = climate_ns.class_("ControlAction", automation.Action) StateTrigger = climate_ns.class_( "StateTrigger", automation.Trigger.template(Climate.operator("ref")) ) @@ -500,78 +494,35 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "climate.control", - ControlAction, CLIMATE_CONTROL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MODE, "set_mode", ClimateMode), + automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_ + ), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_ + ), + automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + automation.ApplyField( + CONF_CUSTOM_FAN_MODE, + "set_fan_mode", + cg.std_string, + const_fn=automation.literal_with_length, + ), + automation.ApplyField(CONF_PRESET, "set_preset", ClimatePreset), + automation.ApplyField( + CONF_CUSTOM_PRESET, + "set_preset", + cg.std_string, + const_fn=automation.literal_with_length, + ), + automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + call="make_call", ) -async def climate_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # For custom_fan_mode/custom_preset the static-string path emits the - # (const char *, size_t) overload of set_fan_mode/set_preset to avoid - # constructing a std::string and calling runtime strlen. - FIELDS = ( - (CONF_MODE, "set_mode", ClimateMode), - (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), - (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), - (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), - (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), - (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), - (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), - (CONF_PRESET, "set_preset", ClimatePreset), - (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), - (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - elif type_ is cg.std_string: - # Static custom strings: emit a flash literal and pass the - # UTF-8 byte length to skip the runtime strlen inside - # set_fan_mode/set_preset. - literal = cg.safe_exp(value) - body_lines.append( - f"call.{setter}({literal}, {len(value.encode('utf-8'))});" - ) - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (ClimateCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index a8d6d778ae4..d6aba02f920 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,34 +5,6 @@ namespace esphome::climate { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `target_temperature: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); - ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->climate_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Climate *climate_; - ApplyFn apply_; -}; - class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index f80de151b11..494efd8edc4 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -435,6 +435,7 @@ void Climate::save_state_(const ClimateTraits &traits) { } void Climate::publish_state() { + this->set_has_state(true); ESP_LOGV(TAG, "'%s' >>", this->name_.c_str()); auto traits = this->get_traits(); diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 0667bd91a26..f5a31b31c4f 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -14,6 +14,8 @@ from esphome.types import ConfigType, SafeExpType _LOGGER = logging.getLogger(__name__) +CONF_SUPPORTS_HEAT_COOL = "supports_heat_cool" + DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] @@ -37,6 +39,7 @@ def climate_ir_schema( { cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, + cv.Optional(CONF_SUPPORTS_HEAT_COOL): cv.boolean, cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), } @@ -61,8 +64,13 @@ def climate_ir_with_receiver_schema( async def register_climate_ir(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await remote_base.register_transmittable(var, config) - cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) - cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) + supports_cool = config[CONF_SUPPORTS_COOL] + supports_heat = config[CONF_SUPPORTS_HEAT] + cg.add(var.set_supports_cool(supports_cool)) + cg.add(var.set_supports_heat(supports_heat)) + # The header default is true, so only the false case needs a call. + if not config.get(CONF_SUPPORTS_HEAT_COOL, supports_cool and supports_heat): + cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) if sensor_id := config.get(CONF_SENSOR): diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index a8edaae6ea8..47143e6e607 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -13,15 +13,7 @@ climate::ClimateTraits ClimateIR::traits() { if (this->humidity_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); - if (this->supports_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - if (this->supports_dry_) - traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (this->supports_fan_only_) - traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); + traits.set_supported_modes(this->modes_); traits.set_visual_min_temperature(this->minimum_temperature_); traits.set_visual_max_temperature(this->maximum_temperature_); @@ -94,9 +86,12 @@ void ClimateIR::dump_config() { " Min. Temperature: %.1f°C\n" " Max. Temperature: %.1f°C\n" " Supports HEAT: %s\n" - " Supports COOL: %s", - this->minimum_temperature_, this->maximum_temperature_, YESNO(this->supports_heat_), - YESNO(this->supports_cool_)); + " Supports COOL: %s\n" + " Supports HEAT_COOL: %s", + this->minimum_temperature_, this->maximum_temperature_, + YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT)), + YESNO(this->modes_.count(climate::CLIMATE_MODE_COOL)), + YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL))); } } // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 6c49b310303..060564b4ba3 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -30,8 +30,10 @@ class ClimateIR : public Component, this->minimum_temperature_ = minimum_temperature; this->maximum_temperature_ = maximum_temperature; this->temperature_step_ = temperature_step; - this->supports_dry_ = supports_dry; - this->supports_fan_only_ = supports_fan_only; + if (supports_dry) + this->modes_.insert(climate::CLIMATE_MODE_DRY); + if (supports_fan_only) + this->modes_.insert(climate::CLIMATE_MODE_FAN_ONLY); this->fan_modes_ = fan_modes; this->swing_modes_ = swing_modes; this->presets_ = presets; @@ -39,8 +41,11 @@ class ClimateIR : public Component, void setup() override; void dump_config() override; - void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } - void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_cool(bool supports_cool) { this->set_mode_supported_(climate::CLIMATE_MODE_COOL, supports_cool); } + void set_supports_heat(bool supports_heat) { this->set_mode_supported_(climate::CLIMATE_MODE_HEAT, supports_heat); } + void set_supports_heat_cool(bool supports_heat_cool) { + this->set_mode_supported_(climate::CLIMATE_MODE_HEAT_COOL, supports_heat_cool); + } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } @@ -58,10 +63,18 @@ class ClimateIR : public Component, // Dummy implement on_receive so implementation is optional for inheritors bool on_receive(remote_base::RemoteReceiveData data) override { return false; }; - bool supports_cool_{true}; - bool supports_heat_{true}; - bool supports_dry_{false}; - bool supports_fan_only_{false}; + ESPHOME_ALWAYS_INLINE void set_mode_supported_(climate::ClimateMode mode, bool supported) { + if (supported) { + this->modes_.insert(mode); + } else { + this->modes_.erase(mode); + } + } + + // The HEAT_COOL default (supports_cool && supports_heat) is resolved during code generation. + static constexpr climate::ClimateModeMask DEFAULT_MODES{climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}; + climate::ClimateModeMask modes_{DEFAULT_MODES}; climate::ClimateFanModeMask fan_modes_{}; climate::ClimateSwingModeMask swing_modes_{}; climate::ClimatePresetMask presets_{}; diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 844bfdfa880..e381db7b5cd 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -23,14 +22,4 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction final : public Action { - public: - CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} - - void play(const Ts &...x) override { this->cm1106_->calibrate_zero(400); } - - protected: - CM1106Component *cm1106_; -}; - } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index a36f0b00594..bf12bc38d91 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,8 +13,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -24,10 +22,6 @@ cm1106_ns = cg.esphome_ns.namespace("cm1106") CM1106Component = cm1106_ns.class_( "CM1106Component", cg.PollingComponent, uart.UARTDevice ) -CM1106CalibrateZeroAction = cm1106_ns.class_( - "CM1106CalibrateZeroAction", - automation.Action, -) CONFIG_SCHEMA = ( cv.Schema( @@ -72,18 +66,8 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "cm1106.calibrate_zero", - CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("calibrate_zero(400)"), ) -async def cm1106_calibration_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - """Service code generation entry point.""" - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49a625e3f1f..97d757c9d46 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_COLUMNS = "columns" +CONF_CONVERSION_RATE = "conversion_rate" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" @@ -23,6 +24,8 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HOST = "host" +CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_IS_WRGB = "is_wrgb" @@ -30,6 +33,7 @@ CONF_KEYS = "keys" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_MANUFACTURER = "manufacturer" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" @@ -60,3 +64,4 @@ ICON_SOLAR_POWER = "mdi:solar-power" KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" +UNIT_COUNTS = "#" diff --git a/esphome/components/const/css_colors.py b/esphome/components/const/css_colors.py new file mode 100644 index 00000000000..a20dc577292 --- /dev/null +++ b/esphome/components/const/css_colors.py @@ -0,0 +1,152 @@ +"""Standard CSS named colors, as 0xRRGGBB values.""" + +CSS_COLORS: dict[str, int] = { + "aliceblue": 0xF0F8FF, + "antiquewhite": 0xFAEBD7, + "aqua": 0x00FFFF, + "aquamarine": 0x7FFFD4, + "azure": 0xF0FFFF, + "beige": 0xF5F5DC, + "bisque": 0xFFE4C4, + "black": 0x000000, + "blanchedalmond": 0xFFEBCD, + "blue": 0x0000FF, + "blueviolet": 0x8A2BE2, + "brown": 0xA52A2A, + "burlywood": 0xDEB887, + "cadetblue": 0x5F9EA0, + "chartreuse": 0x7FFF00, + "chocolate": 0xD2691E, + "coral": 0xFF7F50, + "cornflowerblue": 0x6495ED, + "cornsilk": 0xFFF8DC, + "crimson": 0xDC143C, + "cyan": 0x00FFFF, + "darkblue": 0x00008B, + "darkcyan": 0x008B8B, + "darkgoldenrod": 0xB8860B, + "darkgray": 0xA9A9A9, + "darkgreen": 0x006400, + "darkgrey": 0xA9A9A9, + "darkkhaki": 0xBDB76B, + "darkmagenta": 0x8B008B, + "darkolivegreen": 0x556B2F, + "darkorange": 0xFF8C00, + "darkorchid": 0x9932CC, + "darkred": 0x8B0000, + "darksalmon": 0xE9967A, + "darkseagreen": 0x8FBC8F, + "darkslateblue": 0x483D8B, + "darkslategray": 0x2F4F4F, + "darkslategrey": 0x2F4F4F, + "darkturquoise": 0x00CED1, + "darkviolet": 0x9400D3, + "deeppink": 0xFF1493, + "deepskyblue": 0x00BFFF, + "dimgray": 0x696969, + "dimgrey": 0x696969, + "dodgerblue": 0x1E90FF, + "firebrick": 0xB22222, + "floralwhite": 0xFFFAF0, + "forestgreen": 0x228B22, + "fuchsia": 0xFF00FF, + "gainsboro": 0xDCDCDC, + "ghostwhite": 0xF8F8FF, + "goldenrod": 0xDAA520, + "gold": 0xFFD700, + "gray": 0x808080, + "green": 0x008000, + "greenyellow": 0xADFF2F, + "grey": 0x808080, + "honeydew": 0xF0FFF0, + "hotpink": 0xFF69B4, + "indianred": 0xCD5C5C, + "indigo": 0x4B0082, + "ivory": 0xFFFFF0, + "khaki": 0xF0E68C, + "lavenderblush": 0xFFF0F5, + "lavender": 0xE6E6FA, + "lawngreen": 0x7CFC00, + "lemonchiffon": 0xFFFACD, + "lightblue": 0xADD8E6, + "lightcoral": 0xF08080, + "lightcyan": 0xE0FFFF, + "lightgoldenrodyellow": 0xFAFAD2, + "lightgray": 0xD3D3D3, + "lightgreen": 0x90EE90, + "lightgrey": 0xD3D3D3, + "lightpink": 0xFFB6C1, + "lightsalmon": 0xFFA07A, + "lightseagreen": 0x20B2AA, + "lightskyblue": 0x87CEFA, + "lightslategray": 0x778899, + "lightslategrey": 0x778899, + "lightsteelblue": 0xB0C4DE, + "lightyellow": 0xFFFFE0, + "lime": 0x00FF00, + "limegreen": 0x32CD32, + "linen": 0xFAF0E6, + "magenta": 0xFF00FF, + "maroon": 0x800000, + "mediumaquamarine": 0x66CDAA, + "mediumblue": 0x0000CD, + "mediumorchid": 0xBA55D3, + "mediumpurple": 0x9370DB, + "mediumseagreen": 0x3CB371, + "mediumslateblue": 0x7B68EE, + "mediumspringgreen": 0x00FA9A, + "mediumturquoise": 0x48D1CC, + "mediumvioletred": 0xC71585, + "midnightblue": 0x191970, + "mintcream": 0xF5FFFA, + "mistyrose": 0xFFE4E1, + "moccasin": 0xFFE4B5, + "navajowhite": 0xFFDEAD, + "navy": 0x000080, + "oldlace": 0xFDF5E6, + "olive": 0x808000, + "olivedrab": 0x6B8E23, + "orange": 0xFFA500, + "orangered": 0xFF4500, + "orchid": 0xDA70D6, + "palegoldenrod": 0xEEE8AA, + "palegreen": 0x98FB98, + "paleturquoise": 0xAFEEEE, + "palevioletred": 0xDB7093, + "papayawhip": 0xFFEFD5, + "peachpuff": 0xFFDAB9, + "peru": 0xCD853F, + "pink": 0xFFC0CB, + "plum": 0xDDA0DD, + "powderblue": 0xB0E0E6, + "purple": 0x800080, + "rebeccapurple": 0x663399, + "red": 0xFF0000, + "rosybrown": 0xBC8F8F, + "royalblue": 0x4169E1, + "saddlebrown": 0x8B4513, + "salmon": 0xFA8072, + "sandybrown": 0xF4A460, + "seagreen": 0x2E8B57, + "seashell": 0xFFF5EE, + "sienna": 0xA0522D, + "silver": 0xC0C0C0, + "skyblue": 0x87CEEB, + "slateblue": 0x6A5ACD, + "slategray": 0x708090, + "slategrey": 0x708090, + "snow": 0xFFFAFA, + "springgreen": 0x00FF7F, + "steelblue": 0x4682B4, + "tan": 0xD2B48C, + "teal": 0x008080, + "thistle": 0xD8BFD8, + "tomato": 0xFF6347, + "turquoise": 0x40E0D0, + "violet": 0xEE82EE, + "wheat": 0xF5DEB3, + "white": 0xFFFFFF, + "whitesmoke": 0xF5F5F5, + "yellow": 0xFFFF00, + "yellowgreen": 0x9ACD32, +} diff --git a/esphome/components/coolix/climate.py b/esphome/components/coolix/climate.py index 3eb8dbe2f41..fcca8b89dba 100644 --- a/esphome/components/coolix/climate.py +++ b/esphome/components/coolix/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -12,4 +12,5 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(CoolixClimate) async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("coolix") # used from C++ await climate_ir.new_climate_ir(config) diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 91b76f11c0a..555f0030a5b 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -13,12 +13,6 @@ void CopySwitch::setup() { void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { - if (state) { - source_->turn_on(); - } else { - source_->turn_off(); - } -} +void CopySwitch::write_state(bool state) { this->source_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py new file mode 100644 index 00000000000..c58ce8a01e8 --- /dev/null +++ b/esphome/components/counter/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/counter/counter_sensor.cpp b/esphome/components/counter/counter_sensor.cpp new file mode 100644 index 00000000000..22adb49cca7 --- /dev/null +++ b/esphome/components/counter/counter_sensor.cpp @@ -0,0 +1,28 @@ +#include "counter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::counter { + +static const char *const TAG = "counter"; + +void CounterSensor::setup() { + if (this->restore_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->value_); + } + this->publish_state(static_cast(this->value_)); +} + +void CounterSensor::set_value(int64_t value) { + this->value_ = value; + this->publish_state(static_cast(value)); + if (this->restore_) + this->pref_.save(&this->value_); +} + +void CounterSensor::dump_config() { + LOG_SENSOR("", "Counter", this); + ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_)); +} + +} // namespace esphome::counter diff --git a/esphome/components/counter/counter_sensor.h b/esphome/components/counter/counter_sensor.h new file mode 100644 index 00000000000..3468c4d5a04 --- /dev/null +++ b/esphome/components/counter/counter_sensor.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::counter { + +/// A counter held as a 64-bit integer. The published sensor state is a float, so it is exact only up to 2^24. +class CounterSensor final : public sensor::Sensor, public Component { + public: + /// The counter starts at initial_value unless a stored value is restored. + CounterSensor(bool restore, int64_t initial_value) : value_(initial_value), restore_(restore) {} + + void setup() override; + void dump_config() override; + // restore value before `on_boot` automations run + float get_setup_priority() const override { return setup_priority::DATA + 50.0f; } + + /// Increment by one each time the given sensor publishes a state. + void count_updates_from(sensor::Sensor *source) { + source->add_on_state_callback([this](float) { this->increment(); }); + } + + /// Increment by one each time the given binary sensor changes to true. + template void count_true_from(T *source) { + source->add_on_state_callback([this](bool state) { + if (state) + this->increment(); + }); + } + + void set_value(int64_t value); + /// overflow on addition of signed numbers is undefined - use the well defined unsigned version + void increment(int64_t amount = 1) { + this->set_value(static_cast(static_cast(this->value_) + static_cast(amount))); + } + + protected: + ESPPreferenceObject pref_; + int64_t value_; + bool restore_; +}; + +} // namespace esphome::counter diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py new file mode 100644 index 00000000000..0f36b1721bf --- /dev/null +++ b/esphome/components/counter/sensor.py @@ -0,0 +1,85 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BINARY_SENSOR, + CONF_ID, + CONF_INITIAL_VALUE, + CONF_RESTORE, + CONF_SENSOR, + CONF_VALUE, + ICON_COUNTER, +) +from esphome.types import ConfigType + +counter_ns = cg.esphome_ns.namespace("counter") +CounterSensor = counter_ns.class_("CounterSensor", sensor.Sensor, cg.Component) + +# The lowest value is left out because its C++ literal cannot be written portably. +INT64_MAX = 2**63 - 1 +COUNTER_RANGE = cv.int_range(min=-INT64_MAX, max=INT64_MAX) +COUNTER_VALUE = cv.templatable(COUNTER_RANGE) + + +def _not_own_source(config: ConfigType) -> ConfigType: + if (source := config.get(CONF_SENSOR)) is not None and source.id == config[ + CONF_ID + ].id: + raise cv.Invalid("A counter cannot count its own updates", [CONF_SENSOR]) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + CounterSensor, + icon=ICON_COUNTER, + accuracy_decimals=0, + ) + .extend( + { + cv.Optional(CONF_RESTORE, default=True): cv.boolean, + cv.Optional(CONF_INITIAL_VALUE, default=0): COUNTER_RANGE, + cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_BINARY_SENSOR): cv.use_id(binary_sensor.BinarySensor), + } + ) + .extend(cv.COMPONENT_SCHEMA), + _not_own_source, +) + + +async def to_code(config): + var = cg.new_Pvariable( + config[CONF_ID], config[CONF_RESTORE], config[CONF_INITIAL_VALUE] + ) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + if (source := config.get(CONF_SENSOR)) is not None: + cg.add(var.count_updates_from(await cg.get_variable(source))) + if (source := config.get(CONF_BINARY_SENSOR)) is not None: + cg.add(var.count_true_from(await cg.get_variable(source))) + + +automation.register_apply_action( + "counter.set_value", + cv.maybe_simple_value( + { + cv.GenerateID(CONF_ID): cv.use_id(CounterSensor), + cv.Required(CONF_VALUE): COUNTER_VALUE, + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, "set_value", cg.int64), +) + +automation.register_apply_action( + "counter.increment", + automation.maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(CounterSensor), + cv.Optional(CONF_VALUE, default=1): COUNTER_VALUE, + } + ), + automation.ApplyField(CONF_VALUE, "increment", cg.int64), +) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 011b2c2f04c..0f5aa9b4503 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,9 +1,7 @@ -from collections.abc import Callable -from dataclasses import dataclass import logging from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -38,15 +36,15 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass -from esphome.types import ConfigType, SafeExpType, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -70,7 +68,6 @@ _LOGGER = logging.getLogger(__name__) cover_ns = cg.esphome_ns.namespace("cover") Cover = cover_ns.class_("Cover", cg.EntityBase) -CoverCall = cover_ns.class_("CoverCall") COVER_OPEN = cover_ns.COVER_OPEN COVER_CLOSED = cover_ns.COVER_CLOSED @@ -90,14 +87,6 @@ COVER_OPERATIONS = { validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions -OpenAction = cover_ns.class_("OpenAction", automation.Action) -CloseAction = cover_ns.class_("CloseAction", automation.Action) -StopAction = cover_ns.class_("StopAction", automation.Action) -ToggleAction = cover_ns.class_("ToggleAction", automation.Action) -ControlAction = cover_ns.class_("ControlAction", automation.Action) -CoverPublishAction = cover_ns.class_("CoverPublishAction", automation.Action) -CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) -CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( "CoverOpenedTrigger", automation.Trigger.template() ) @@ -256,56 +245,15 @@ COVER_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_open_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_close_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_toggle_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _command in ( + ("cover.open", "set_command_open()"), + ("cover.close", "set_command_close()"), + ("cover.stop", "set_command_stop()"), + ("cover.toggle", "set_command_toggle()"), +): + automation.register_apply_action( + _name, COVER_ACTION_SCHEMA, automation.ApplyCall(_command), call="make_call" + ) COVER_CONTROL_ACTION_SCHEMA = cv.Schema( @@ -319,125 +267,30 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@dataclass(frozen=True) -class ApplyField: - """One field in a folded-lambda action. - - `conf_key` is the YAML key looked up in `config`. When present, the - helper emits `statement_fn(target, value_expr)` into the lambda body. - `target` is whatever the statement function needs to identify the - field (typically a setter name like `"set_position"` or a struct - member like `"position"`). `type_` is the C++ return type for - `cg.process_lambda` when the value is a user lambda. - """ - - conf_key: str - target: str - type_: object - - -async def build_apply_lambda_action( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, - fields: tuple[ApplyField, ...], - prefix_args: list[tuple[object, str]], - statement_fn: Callable[[str, str], str], -) -> MockObj: - """Fold configured fields into a single stateless apply lambda action. - - Used by both `cover.control` and `cover.template.publish` (and shared - with the template/cover platform). Constants are emitted as flash - immediates; user lambdas are invoked inline so trigger args still flow. - Trigger arg types are normalized to `const std::remove_cvref_t &` - to match the ApplyFn signature for any T (value, ref, or const-ref). - """ - paren = await cg.get_variable(config[CONF_ID]) - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for field in fields: - if (value := config.get(field.conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, normalized_args, return_type=field.type_ - ) - value_expr = f"({inner})({fwd_args})" - else: - value_expr = str(cg.safe_exp(value)) - body_lines.append(statement_fn(field.target, value_expr)) - - apply_args = [ - *prefix_args, - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) - - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both dispatch to set_position. -_COVER_CONTROL_FIELDS: tuple[ApplyField, ...] = ( - ApplyField(CONF_STOP, "set_stop", cg.bool_), - ApplyField(CONF_STATE, "set_position", cg.float_), - ApplyField(CONF_POSITION, "set_position", cg.float_), - ApplyField(CONF_TILT, "set_tilt", cg.float_), +automation.register_apply_action( + "cover.control", + COVER_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STATE, "set_position", cg.float_), + automation.ApplyField(CONF_POSITION, "set_position", cg.float_), + automation.ApplyField(CONF_TILT, "set_tilt", cg.float_), + call="make_call", ) -@automation.register_action( - "cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True -) -async def cover_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return await build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_CONTROL_FIELDS, - prefix_args=[(CoverCall.operator("ref"), "call")], - statement_fn=lambda setter, expr: f"call.{setter}({expr});", - ) - - COVER_CONDITION_SCHEMA = cv.maybe_simple_value( {cv.Required(CONF_ID): cv.use_id(Cover)}, key=CONF_ID ) -async def cover_condition_to_code( - config: ConfigType, condition_id: ID, template_arg: MockObj, args: TemplateArgsType -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) - - -automation.register_condition( - "cover.is_open", CoverIsOpenCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) -automation.register_condition( - "cover.is_closed", CoverIsClosedCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) +automation.register_apply_condition( + "cover.is_open", COVER_CONDITION_SCHEMA, f"position == {COVER_OPEN}" +) +automation.register_apply_condition( + "cover.is_closed", COVER_CONDITION_SCHEMA, f"position == {COVER_CLOSED}" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 0a5a447ab92..2032eba0ec3 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,103 +6,6 @@ namespace esphome::cover { -template class OpenAction final : public Action { - public: - explicit OpenAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_open().perform(); } - - protected: - Cover *cover_; -}; - -template class CloseAction final : public Action { - public: - explicit CloseAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_close().perform(); } - - protected: - Cover *cover_; -}; - -template class StopAction final : public Action { - public: - explicit StopAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_stop().perform(); } - - protected: - Cover *cover_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_toggle().perform(); } - - protected: - Cover *cover_; -}; - -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. Each action stores only one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `position: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. - -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); - ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->cover_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Cover *cover_; - ApplyFn apply_; -}; - -template class CoverPublishAction final : public Action { - public: - using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); - CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} - - void play(const Ts &...x) override { - this->apply_(this->cover_, x...); - this->cover_->publish_state(); - } - - protected: - Cover *cover_; - ApplyFn apply_; -}; - -template class CoverPositionCondition final : public Condition { - public: - CoverPositionCondition(Cover *cover) : cover_(cover) {} - - bool check(const Ts &...x) override { return this->cover_->position == (OPEN ? COVER_OPEN : COVER_CLOSED); } - - protected: - Cover *cover_; -}; - -template using CoverIsOpenCondition = CoverPositionCondition; -template using CoverIsClosedCondition = CoverPositionCondition; - template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index 87ea858c70c..80a63d808fb 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" @@ -108,14 +107,4 @@ class CS5460AComponent final : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction final : public Action { - public: - CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} - - void play(const Ts &...x) override { cs5460a_->restart(); } - - protected: - CS5460AComponent *cs5460a_; -}; - } // namespace esphome::cs5460a diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 5f14457101f..17ea41ed04d 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,8 +17,6 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@balrog-kun"] @@ -32,7 +30,6 @@ PGA_GAIN_OPTIONS = { } CS5460AComponent = cs5460a_ns.class_("CS5460AComponent", spi.SPIDevice, cg.Component) -CS5460ARestartAction = cs5460a_ns.class_("CS5460ARestartAction", automation.Action) CONF_SAMPLES = "samples" CONF_PHASE_OFFSET = "phase_offset" @@ -131,21 +128,12 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_power_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "cs5460a.restart", - CS5460ARestartAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(CS5460AComponent), } ), - synchronous=True, + automation.ApplyCall("restart()"), ) -async def restart_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 9bed0f3f59e..86b6965e3a2 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -98,24 +98,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if apparent_power_config := config.get(CONF_APPARENT_POWER): - sens = await sensor.new_sensor(apparent_power_config) - cg.add(var.set_apparent_power_sensor(sens)) - if reactive_power_config := config.get(CONF_REACTIVE_POWER): - sens = await sensor.new_sensor(reactive_power_config) - cg.add(var.set_reactive_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index ab4a8c458fe..c418362ced1 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -6,6 +6,7 @@ namespace esphome::dallas_temp { static const char *const TAG = "dallas.temp.sensor"; static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; +static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE; static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E; @@ -154,7 +155,14 @@ float DallasTemperatureSensor::get_temp_c_() { default: break; } - + // undocumented test for powerup measurement of 85 + // https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem + if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) { + if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) { + ESP_LOGD(TAG, "dropping reading caused by sensor reset"); + return NAN; + } + } return temp / 16.0f; } diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index f8b6446006e..3a33e5e0044 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, time, web_server @@ -21,13 +23,13 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -40,11 +42,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase) -# Actions -DateSetAction = datetime_ns.class_("DateSetAction", automation.Action) -TimeSetAction = datetime_ns.class_("TimeSetAction", automation.Action) -DateTimeSetAction = datetime_ns.class_("DateTimeSetAction", automation.Action) - DateTimeStateTrigger = datetime_ns.class_( "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) ) @@ -181,118 +178,64 @@ async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) -@automation.register_action( - "datetime.date.set", - DateSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateEntity), - cv.Required(CONF_DATE): cv.Any( - cv.returning_lambda, cv.date_time(date=True, time=False) - ), - } - ), - synchronous=True, -) -async def datetime_date_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - date_config = config[CONF_DATE] - if cg.is_template(date_config): - template_ = await cg.templatable(date_config, args, cg.ESPTime) - cg.add(action_var.set_date(template_)) - else: - date_struct = cg.StructInitializer( - cg.ESPTime, - ("day_of_month", date_config[CONF_DAY]), - ("month", date_config[CONF_MONTH]), - ("year", date_config[CONF_YEAR]), +def _esptime_initializer( + members: tuple[tuple[str, str], ...], +) -> Callable[[ConfigType, ConfigType], str]: + def const_fn(config: ConfigType, value: ConfigType) -> str: + return str( + cg.StructInitializer( + cg.ESPTime, *((member, value[key]) for member, key in members) + ) ) - template_ = await cg.templatable(date_struct, args, cg.ESPTime) - cg.add(action_var.set_date(template_)) - return action_var + + return const_fn -@automation.register_action( - "datetime.time.set", - TimeSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(TimeEntity), - cv.Required(CONF_TIME): cv.Any( - cv.returning_lambda, cv.date_time(date=False, time=True) - ), - } +# ESPTime member order, so the designated initializer compiles. +_TIME_MEMBERS = (("second", CONF_SECOND), ("minute", CONF_MINUTE), ("hour", CONF_HOUR)) +_DATE_MEMBERS = (("day_of_month", CONF_DAY), ("month", CONF_MONTH), ("year", CONF_YEAR)) + +for _name, _entity, _key, _target, _date, _time, _members in ( + ( + "datetime.date.set", + DateEntity, + CONF_DATE, + "set_date", + True, + False, + _DATE_MEMBERS, ), - synchronous=True, -) -async def datetime_time_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - time_config = config[CONF_TIME] - if cg.is_template(time_config): - template_ = await cg.templatable(time_config, args, cg.ESPTime) - cg.add(action_var.set_time(template_)) - else: - time_struct = cg.StructInitializer( - cg.ESPTime, - ("second", time_config[CONF_SECOND]), - ("minute", time_config[CONF_MINUTE]), - ("hour", time_config[CONF_HOUR]), - ) - template_ = await cg.templatable(time_struct, args, cg.ESPTime) - cg.add(action_var.set_time(template_)) - return action_var - - -@automation.register_action( - "datetime.datetime.set", - DateTimeSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateTimeEntity), - cv.Required(CONF_DATETIME): cv.Any( - cv.returning_lambda, cv.date_time(date=True, time=True) - ), - }, + ( + "datetime.time.set", + TimeEntity, + CONF_TIME, + "set_time", + False, + True, + _TIME_MEMBERS, ), - synchronous=True, -) -async def datetime_datetime_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - datetime_config = config[CONF_DATETIME] - if cg.is_template(datetime_config): - template_ = await cg.templatable(datetime_config, args, cg.ESPTime) - cg.add(action_var.set_datetime(template_)) - else: - datetime_struct = cg.StructInitializer( - cg.ESPTime, - ("second", datetime_config[CONF_SECOND]), - ("minute", datetime_config[CONF_MINUTE]), - ("hour", datetime_config[CONF_HOUR]), - ("day_of_month", datetime_config[CONF_DAY]), - ("month", datetime_config[CONF_MONTH]), - ("year", datetime_config[CONF_YEAR]), - ) - template_ = await cg.templatable(datetime_struct, args, cg.ESPTime) - cg.add(action_var.set_datetime(template_)) - return action_var + ( + "datetime.datetime.set", + DateTimeEntity, + CONF_DATETIME, + "set_datetime", + True, + True, + _TIME_MEMBERS + _DATE_MEMBERS, + ), +): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(_entity), + cv.Required(_key): cv.Any( + cv.returning_lambda, cv.date_time(date=_date, time=_time) + ), + } + ), + automation.ApplyField( + _key, _target, cg.ESPTime, const_fn=_esptime_initializer(_members) + ), + call="make_call", + ) diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 93ce1411f87..9404d215120 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -98,20 +98,6 @@ class DateCall { inline DateCall DateEntity::make_call() { return DateCall(this); } -template class DateSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, date) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->date_.has_value()) { - call.set_date(this->date_.value(x...)); - } - call.perform(); - } -}; - } // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index fec620b5ba9..57d305cb52b 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -123,20 +123,6 @@ class DateTimeCall { inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } -template class DateTimeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, datetime) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->datetime_.has_value()) { - call.set_datetime(this->datetime_.value(x...)); - } - call.perform(); - } -}; - #ifdef USE_TIME class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 736e26f4a7a..fd31318ce5f 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -100,20 +100,6 @@ class TimeCall { inline TimeCall TimeEntity::make_call() { return TimeCall(this); } -template class TimeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, time) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->time_.has_value()) { - call.set_time(this->time_.value(x...)); - } - call.perform(); - } -}; - #ifdef USE_TIME class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index a889d13329b..898d7d6c0e7 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -51,13 +52,15 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("HWINFO", True) # gdb thread support zephyr_add_prj_conf("DEBUG_THREAD_INFO", True) - # RTT + # RTT sources live in the segger project + include_west_project("segger") zephyr_add_prj_conf("USE_SEGGER_RTT", True) zephyr_add_prj_conf("RTT_CONSOLE", True) zephyr_add_prj_conf("LOG", True) zephyr_add_prj_conf("LOG_BLOCK_IN_THREAD", True) zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096) zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True) + zephyr_add_prj_conf("PRINTK", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add_define("USE_DEBUG") diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 969cd840cf6..8e1a67224eb 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -66,11 +66,15 @@ const char *DebugComponent::get_reset_reason_(std::spanmake_preference(REBOOT_MAX_LEN, fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); char reboot_source[REBOOT_MAX_LEN]{}; - if (pref.load(&reboot_source)) { + if (pref.load(&reboot_source) && reboot_source[0] != '\0') { reboot_source[REBOOT_MAX_LEN - 1] = '\0'; snprintf(buf, size, "Reboot request from %s", reboot_source); } else { diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index e53cb0d1e4a..07c67a2b937 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -23,11 +23,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] @@ -110,32 +106,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if free_conf := config.get(CONF_FREE): - sens = await sensor.new_sensor(free_conf) - cg.add(debug_component.set_free_sensor(sens)) - - if block_conf := config.get(CONF_BLOCK): - sens = await sensor.new_sensor(block_conf) - cg.add(debug_component.set_block_sensor(sens)) - - if fragmentation_conf := config.get(CONF_FRAGMENTATION): - sens = await sensor.new_sensor(fragmentation_conf) - cg.add(debug_component.set_fragmentation_sensor(sens)) - - if min_free_conf := config.get(CONF_MIN_FREE): - sens = await sensor.new_sensor(min_free_conf) - cg.add(debug_component.set_min_free_sensor(sens)) - - if loop_time_conf := config.get(CONF_LOOP_TIME): - sens = await sensor.new_sensor(loop_time_conf) - cg.add(debug_component.set_loop_time_sensor(sens)) - - if psram_conf := config.get(CONF_PSRAM): - sens = await sensor.new_sensor(psram_conf) - cg.add(debug_component.set_psram_sensor(sens)) - - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): - sens = await sensor.new_sensor(cpu_freq_conf) - cg.add(debug_component.set_cpu_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREE, hub.set_free_sensor) + await sensors(CONF_BLOCK, hub.set_block_sensor) + await sensors(CONF_FRAGMENTATION, hub.set_fragmentation_sensor) + await sensors(CONF_MIN_FREE, hub.set_min_free_sensor) + await sensors(CONF_LOOP_TIME, hub.set_loop_time_sensor) + await sensors(CONF_PSRAM, hub.set_psram_sensor) + await sensors(CONF_CPU_FREQUENCY, hub.set_cpu_frequency_sensor) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 9d4fcc1b42a..43ed0d15c62 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -9,11 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] @@ -35,11 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if CONF_DEVICE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DEVICE]) - cg.add(debug_component.set_device_info_sensor(sens)) - if CONF_RESET_REASON in config: - sens = await text_sensor.new_text_sensor(config[CONF_RESET_REASON]) - cg.add(debug_component.set_reset_reason_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DEVICE, hub.set_device_info_sensor) + await text_sensors(CONF_RESET_REASON, hub.set_reset_reason_sensor) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index dc037086452..06286933b90 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -214,16 +214,6 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) -PreventDeepSleepAction = deep_sleep_ns.class_( - "PreventDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) -AllowDeepSleepAction = deep_sleep_ns.class_( - "AllowDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") WAKEUP_PIN_MODES = { @@ -422,8 +412,12 @@ async def to_code(config: ConfigType) -> None: if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) - if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: - zephyr_add_prj_conf("POWEROFF", True) + if CORE.using_zephyr: + # Devices are suspended when CPU is entering a low power state + # https://github.com/nrfconnect/sdk-zephyr/blob/v3.7.99-ncs2-2/doc/services/pm/device.rst#system-managed-device-power-management + zephyr_add_prj_conf("PM_DEVICE", True) + if "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") @@ -486,27 +480,15 @@ async def deep_sleep_enter_to_code( return var -@automation.register_action( - "deep_sleep.prevent", - PreventDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -@automation.register_action( - "deep_sleep.allow", - AllowDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -async def deep_sleep_action_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]) - return var +for _name, _call in ( + ("deep_sleep.prevent", "prevent_deep_sleep()"), + ("deep_sleep.allow", "allow_deep_sleep()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + automation.ApplyCall(_call), + ) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 2c97dc32114..a955095875b 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } } diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 9a3e537e051..d33102bf4fa 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -17,6 +17,7 @@ void DeepSleepComponent::setup() { void DeepSleepComponent::schedule_sleep_() { this->next_enter_deep_sleep_ = false; + this->disable_loop(); const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -45,7 +46,7 @@ void DeepSleepComponent::loop() { void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 208f88d7074..2afe19c0698 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -121,8 +121,6 @@ class Ext1WakeTrigger : public Trigger<>, public Component { template class EnterDeepSleepAction; -template class PreventDeepSleepAction; - /** This component allows setting up the node to go into deep sleep mode to conserve battery. * * To set this component up, first set *when* the deep sleep should trigger using set_run_cycles @@ -190,6 +188,11 @@ class DeepSleepComponent final : public Component { void schedule_sleep_(); bool should_teardown_(); + void defer_sleep_() { + this->next_enter_deep_sleep_ = true; + this->enable_loop(); + } + #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } @@ -293,15 +296,4 @@ template class EnterDeepSleepAction final : public Action #endif }; -template -class PreventDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } -}; - -template class AllowDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } -}; - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 3fa1a1f1edf..20297028b2c 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGW(TAG, "Waiting for wakeup pin state change"); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } return true; diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index bb18e6ba8c6..296afcd3183 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import uart @@ -10,12 +12,10 @@ CODEOWNERS = ["@glmnet"] dfplayer_ns = cg.esphome_ns.namespace("dfplayer") DFPlayer = dfplayer_ns.class_("DFPlayer", cg.Component) -DFPlayerIsPlayingCondition = dfplayer_ns.class_( - "DFPlayerIsPlayingCondition", automation.Condition -) MULTI_CONF = True CONF_FOLDER = "folder" +CONF_ENABLE = "enable" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" @@ -34,23 +34,6 @@ DEVICE = { "TF_CARD": Device.TF_CARD, } -NextAction = dfplayer_ns.class_("NextAction", automation.Action) -PreviousAction = dfplayer_ns.class_("PreviousAction", automation.Action) -PlayMp3Action = dfplayer_ns.class_("PlayMp3Action", automation.Action) -PlayFileAction = dfplayer_ns.class_("PlayFileAction", automation.Action) -PlayFolderAction = dfplayer_ns.class_("PlayFolderAction", automation.Action) -SetVolumeAction = dfplayer_ns.class_("SetVolumeAction", automation.Action) -VolumeUpAction = dfplayer_ns.class_("VolumeUpAction", automation.Action) -VolumeDownAction = dfplayer_ns.class_("VolumeDownAction", automation.Action) -SetEqAction = dfplayer_ns.class_("SetEqAction", automation.Action) -SleepAction = dfplayer_ns.class_("SleepAction", automation.Action) -ResetAction = dfplayer_ns.class_("ResetAction", automation.Action) -StartAction = dfplayer_ns.class_("StartAction", automation.Action) -PauseAction = dfplayer_ns.class_("PauseAction", automation.Action) -StopAction = dfplayer_ns.class_("StopAction", automation.Action) -RandomAction = dfplayer_ns.class_("RandomAction", automation.Action) -SetDeviceAction = dfplayer_ns.class_("SetDeviceAction", automation.Action) - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -84,41 +67,30 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( - "dfplayer.play_next", - NextAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, +DFPLAYER_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(DFPlayer), + } ) -async def dfplayer_next_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("dfplayer.play_next", "next()"), + ("dfplayer.play_previous", "previous()"), + ("dfplayer.volume_up", "volume_up()"), + ("dfplayer.volume_down", "volume_down()"), + ("dfplayer.sleep", "sleep()"), + ("dfplayer.reset", "reset()"), + ("dfplayer.start", "start()"), + ("dfplayer.pause", "pause()"), + ("dfplayer.stop", "stop()"), + ("dfplayer.random", "random()"), +): + automation.register_apply_action( + _name, DFPLAYER_ACTION_SCHEMA, automation.ApplyCall(_call) + ) -@automation.register_action( - "dfplayer.play_previous", - PreviousAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_previous_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( +automation.register_apply_action( "dfplayer.play_mp3", - PlayMp3Action, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -126,70 +98,43 @@ async def dfplayer_previous_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), - synchronous=True, + automation.ApplyField(CONF_FILE, "play_mp3", cg.uint16), ) -async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - return var - -@automation.register_action( +# loop and file default to what the old action's unset templatable values evaluated to +automation.register_apply_action( "dfplayer.play", - PlayFileAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), cv.Required(CONF_FILE): cv.templatable(cv.int_), - cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), + cv.Optional(CONF_LOOP, default=False): cv.templatable(cv.boolean), }, key=CONF_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {})", ((CONF_FILE, cg.uint16), (CONF_LOOP, cg.bool_)) + ), ) -async def dfplayer_play_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) - cg.add(var.set_loop(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.play_folder", - PlayFolderAction, cv.Schema( { cv.GenerateID(): cv.use_id(DFPlayer), cv.Required(CONF_FOLDER): cv.templatable(cv.int_), - cv.Optional(CONF_FILE): cv.templatable(cv.int_), - cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), + cv.Optional(CONF_FILE, default=0): cv.templatable(cv.int_), + cv.Optional(CONF_LOOP, default=False): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyCall( + "play_folder({}, {}, {})", + ((CONF_FOLDER, cg.uint16), (CONF_FILE, cg.uint16), (CONF_LOOP, cg.bool_)), + ), ) -async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FOLDER], args, cg.uint16) - cg.add(var.set_folder(template_)) - if CONF_FILE in config: - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) - cg.add(var.set_loop(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_device", - SetDeviceAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -197,19 +142,11 @@ async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): }, key=CONF_DEVICE, ), - synchronous=True, + automation.ApplyField(CONF_DEVICE, "set_device", Device), ) -async def dfplayer_set_device_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_DEVICE], args, Device) - cg.add(var.set_device(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_volume", - SetVolumeAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -217,51 +154,11 @@ async def dfplayer_set_device_to_code(config, action_id, template_arg, args): }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.uint8), ) -async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.uint8) - cg.add(var.set_volume(template_)) - return var - -@automation.register_action( - "dfplayer.volume_up", - VolumeUpAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.volume_down", - VolumeDownAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_eq", - SetEqAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -269,122 +166,42 @@ async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): }, key=CONF_EQ_PRESET, ), - synchronous=True, + automation.ApplyField(CONF_EQ_PRESET, "set_eq", EqPreset), ) -async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_EQ_PRESET], args, EqPreset) - cg.add(var.set_eq(template_)) - return var -@automation.register_action( - "dfplayer.sleep", - SleepAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } +def _default_enable(value: Any) -> Any: + """Fill in ``enable: true`` for a bare action or a mapping that only picks the player. + + Done before ``maybe_simple_value`` so neither form is wrapped as the ``enable`` value. + """ + if value is None or isinstance(value, dict): + return {CONF_ENABLE: True, **(value or {})} + return value + + +automation.register_apply_action( + "dfplayer.set_current_track_repeat", + cv.All( + _default_enable, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(DFPlayer), + cv.Optional(CONF_ENABLE, default=True): cv.templatable(cv.boolean), + }, + key=CONF_ENABLE, + ), ), - synchronous=True, + automation.ApplyField(CONF_ENABLE, "set_current_track_repeat", cg.bool_), ) -async def dfplayer_sleep_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( - "dfplayer.reset", - ResetAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_reset_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.start", - StartAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_start_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.pause", - PauseAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_pause_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.stop", - StopAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_stop_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.random", - RandomAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_random_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_condition( +automation.register_apply_condition( "dfplayer.is_playing", - DFPlayerIsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(DFPlayer), } ), + "is_playing()", ) -async def dfplayer_is_playing_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index f81d1cd1b6a..1195f595c8c 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -102,6 +102,13 @@ void DFPlayer::random() { this->send_cmd_(0x18); } +void DFPlayer::set_current_track_repeat(bool enable) { + uint16_t arg = enable ? 0x00 : 0x01; + ESP_LOGD(TAG, "Setting current track repeat to %s", + enable ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); + this->send_cmd_(0x19, arg); +} + void DFPlayer::play_folder(uint16_t folder, uint16_t file) { ESP_LOGD(TAG, "Playing file %d in folder %d", file, folder); if (folder < 100 && file < 256) { diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 1db6b394c58..957583e4ffb 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -33,8 +33,13 @@ class DFPlayer final : public uart::UARTDevice, public Component { void play_mp3(uint16_t file); void play_file(uint16_t file); void play_file_loop(uint16_t file); + void play_file(uint16_t file, bool loop) { loop ? this->play_file_loop(file) : this->play_file(file); } void play_folder(uint16_t folder, uint16_t file); void play_folder_loop(uint16_t folder); + // The loop command plays the whole folder, so file is ignored when loop is set. + void play_folder(uint16_t folder, uint16_t file, bool loop) { + loop ? this->play_folder_loop(folder) : this->play_folder(folder, file); + } void volume_up(); void volume_down(); void set_device(Device device); @@ -46,6 +51,7 @@ class DFPlayer final : public uart::UARTDevice, public Component { void pause(); void stop(); void random(); + void set_current_track_repeat(bool enable); bool is_playing() { return is_playing_; } void dump_config() override; @@ -71,103 +77,4 @@ class DFPlayer final : public uart::UARTDevice, public Component { CallbackManager on_finished_playback_callback_; }; -#define DFPLAYER_SIMPLE_ACTION(ACTION_CLASS, ACTION_METHOD) \ - template \ - class ACTION_CLASS : /* NOLINT */ \ - public Action, \ - public Parented { \ - void play(const Ts &...x) override { this->parent_->ACTION_METHOD(); } \ - }; - -DFPLAYER_SIMPLE_ACTION(NextAction, next) -DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) - -template class PlayMp3Action final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, file) - - void play(const Ts &...x) override { - auto file = this->file_.value(x...); - this->parent_->play_mp3(file); - } -}; - -template class PlayFileAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, file) - TEMPLATABLE_VALUE(bool, loop) - - void play(const Ts &...x) override { - auto file = this->file_.value(x...); - auto loop = this->loop_.value(x...); - if (loop) { - this->parent_->play_file_loop(file); - } else { - this->parent_->play_file(file); - } - } -}; - -template class PlayFolderAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, folder) - TEMPLATABLE_VALUE(uint16_t, file) - TEMPLATABLE_VALUE(bool, loop) - - void play(const Ts &...x) override { - auto folder = this->folder_.value(x...); - auto file = this->file_.value(x...); - auto loop = this->loop_.value(x...); - if (loop) { - this->parent_->play_folder_loop(folder); - } else { - this->parent_->play_folder(folder, file); - } - } -}; - -template class SetDeviceAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Device, device) - - void play(const Ts &...x) override { - auto device = this->device_.value(x...); - this->parent_->set_device(device); - } -}; - -template class SetVolumeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, volume) - - void play(const Ts &...x) override { - auto volume = this->volume_.value(x...); - this->parent_->set_volume(volume); - } -}; - -template class SetEqAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(EqPreset, eq) - - void play(const Ts &...x) override { - auto eq = this->eq_.value(x...); - this->parent_->set_eq(eq); - } -}; - -DFPLAYER_SIMPLE_ACTION(SleepAction, sleep) -DFPLAYER_SIMPLE_ACTION(ResetAction, reset) -DFPLAYER_SIMPLE_ACTION(StartAction, start) -DFPLAYER_SIMPLE_ACTION(PauseAction, pause) -DFPLAYER_SIMPLE_ACTION(StopAction, stop) -DFPLAYER_SIMPLE_ACTION(RandomAction, random) -DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) -DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) - -template class DFPlayerIsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::dfplayer diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 51562f923cb..6334e10ac5f 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -20,9 +20,6 @@ DfrobotSen0395Component = dfrobot_sen0395_ns.class_( ) # Actions -DfrobotSen0395ResetAction = dfrobot_sen0395_ns.class_( - "DfrobotSen0395ResetAction", automation.Action -) DfrobotSen0395SettingsAction = dfrobot_sen0395_ns.class_( "DfrobotSen0395SettingsAction", automation.Action ) @@ -49,26 +46,15 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) -@automation.register_action( +automation.register_apply_action( "dfrobot_sen0395.reset", - DfrobotSen0395ResetAction, maybe_simple_id( { cv.GenerateID(): cv.use_id(DfrobotSen0395Component), } ), - synchronous=True, + automation.ApplyCall("reset_system()"), ) -async def dfrobot_sen0395_reset_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]) - - return var def range_segment_list(input: Any) -> list: @@ -201,19 +187,18 @@ async def dfrobot_sen0395_settings_to_code( template_ = await cg.templatable(segments[7], args, cg.float_) cg.add(var.set_det_max4(template_)) if CONF_OUTPUT_LATENCY in config: - template_ = await cg.templatable( - config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DETECT], args, float - ) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds / 1000 - cg.add(var.set_delay_after_detect(template_)) - - template_ = await cg.templatable( - config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DISAPPEAR], args, float - ) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds / 1000 - cg.add(var.set_delay_after_disappear(template_)) + latency = config[CONF_OUTPUT_LATENCY] + for key, setter in ( + (CONF_DELAY_AFTER_DETECT, var.set_delay_after_detect), + (CONF_DELAY_AFTER_DISAPPEAR, var.set_delay_after_disappear), + ): + template_ = await cg.templatable( + latency[key], + args, + float, + to_exp=lambda period: period.total_milliseconds / 1000, + ) + cg.add(setter(template_)) if CONF_SENSITIVITY in config: template_ = await cg.templatable(config[CONF_SENSITIVITY], args, cg.int8) cg.add(var.set_sensitivity(template_)) diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index a5f4c99014d..c3632085310 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -7,12 +7,6 @@ namespace esphome::dfrobot_sen0395 { -template -class DfrobotSen0395ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } -}; - template class DfrobotSen0395SettingsAction final : public Action, public Parented { public: diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 448a18a4775..500f20d8dab 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -94,6 +94,8 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component #endif int8_t enqueue(std::unique_ptr cmd); + /// Queue a sensor reset + void reset_system() { this->enqueue(make_unique()); } protected: #ifdef USE_BINARY_SENSOR diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 7a66da11f2b..1cd75ddaf07 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -39,15 +39,6 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayPageShowNextAction = display_ns.class_( - "DisplayPageShowNextAction", automation.Action -) -DisplayPageShowPrevAction = display_ns.class_( - "DisplayPageShowPrevAction", automation.Action -) -DisplayIsDisplayingPageCondition = display_ns.class_( - "DisplayIsDisplayingPageCondition", automation.Condition -) DisplayOnPageChangeTrigger = display_ns.class_( "DisplayOnPageChangeTrigger", automation.Trigger ) @@ -288,39 +279,27 @@ async def display_page_show_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +# The id is the display itself, so it cannot be a lambda; a plain use_id rejects one clearly. +DISPLAY_PAGE_CYCLE_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(Display), + } +) + +automation.register_apply_action( "display.page.show_next", - DisplayPageShowNextAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_next_page()"), ) -async def display_page_show_next_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( +automation.register_apply_action( "display.page.show_previous", - DisplayPageShowPrevAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_prev_page()"), ) -async def display_page_show_previous_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition( +automation.register_apply_condition( "display.is_displaying_page", - DisplayIsDisplayingPageCondition, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Display), @@ -328,13 +307,8 @@ async def display_page_show_previous_to_code(config, action_id, template_arg, ar }, key=CONF_PAGE_ID, ), + automation.ApplyCall("get_active_page() == {}", ((CONF_PAGE_ID, DisplayPagePtr),)), ) -async def display_is_displaying_page_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - page = await cg.get_variable(config[CONF_PAGE_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - cg.add(var.set_page(page)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c2d45dbb600..66fadf12ece 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -3,6 +3,7 @@ #include #include #include "display_color_utils.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -770,10 +771,12 @@ Rect Display::get_clipping() const { void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } +void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; return true; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index a9ffda422d1..bebeba8dcb2 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -758,6 +758,13 @@ class Display : public PollingComponent { bool is_clipping() const { return !this->clipping_rectangle_.empty(); } + /// Whether (x, y) falls outside the active clipping rectangle. Tests the + /// stack top in place: get_clipping() is out of line and returns the Rect + /// by value, which per pixel drawing cannot afford. + bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const { + return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y); + } + /** Check if pixel is within region of display. */ bool clip(int x, int y); @@ -774,6 +781,17 @@ class Display : public PollingComponent { void do_update_(); void clear_clipping_(); + /// Watchdog feed for per pixel loops. App.feed_wdt() is already rate + /// limited, but every call reads the clock; only every 256th pixel makes + /// that call, so the real feeds are unchanged and a pixel costs a counter. + /// At 20 us per pixel on the slowest e-paper path that is about 5 ms + /// between clock reads. + void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() { + if (++this->wdt_pixel_counter_ == 0) + this->feed_wdt_pixel_slow_(); + } + void feed_wdt_pixel_slow_(); + virtual int get_height_internal() = 0; virtual int get_width_internal() = 0; @@ -793,6 +811,7 @@ class Display : public PollingComponent { std::vector on_page_change_triggers_; bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; + uint8_t wdt_pixel_counter_{0}; bool show_test_card_{false}; }; @@ -829,36 +848,6 @@ template class DisplayPageShowAction final : public Action class DisplayPageShowNextAction final : public Action { - public: - DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} - - void play(const Ts &...x) override { this->buffer_->show_next_page(); } - - Display *buffer_; -}; - -template class DisplayPageShowPrevAction final : public Action { - public: - DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} - - void play(const Ts &...x) override { this->buffer_->show_prev_page(); } - - Display *buffer_; -}; - -template class DisplayIsDisplayingPageCondition final : public Condition { - public: - DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} - - void set_page(DisplayPage *page) { this->page_ = page; } - bool check(const Ts &...x) override { return this->parent_->get_active_page() == this->page_; } - - protected: - Display *parent_; - DisplayPage *page_; -}; - class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 4c919140494..d564ea67bd5 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -2,7 +2,6 @@ #include -#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::display { @@ -44,7 +43,7 @@ int DisplayBuffer::get_height() { } void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { break; } this->draw_absolute_pixel_internal(x, y, color); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } } // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index a47f7269175..3ecf6d1cf15 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const { return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h); } -bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT - if (!this->is_set()) { - return true; - } - if (absolute) { - return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); - } - return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; -} - bool Rect::inside(Rect rect) const { if (!this->is_set() || !rect.is_set()) { return true; diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index f4958fab88c..d65d844b9e6 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -26,7 +26,15 @@ class Rect { void shrink(Rect rect); bool inside(Rect rect) const; - bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const; + bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const { + if (!this->is_set()) { + return true; + } + if (absolute) { + return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); + } + return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; + } bool equal(Rect rect) const; void info(const std::string &prefix = "rect info:"); }; diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 2120abe5f79..bb493ae8d33 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -55,18 +55,6 @@ MenuItemSwitch = display_menu_base_ns.class_("MenuItemSwitch") MenuItemCommand = display_menu_base_ns.class_("MenuItemCommand") MenuItemCustom = display_menu_base_ns.class_("MenuItemCustom") -UpAction = display_menu_base_ns.class_("UpAction", automation.Action) -DownAction = display_menu_base_ns.class_("DownAction", automation.Action) -LeftAction = display_menu_base_ns.class_("LeftAction", automation.Action) -RightAction = display_menu_base_ns.class_("RightAction", automation.Action) -EnterAction = display_menu_base_ns.class_("EnterAction", automation.Action) -ShowAction = display_menu_base_ns.class_("ShowAction", automation.Action) -HideAction = display_menu_base_ns.class_("HideAction", automation.Action) -ShowMainAction = display_menu_base_ns.class_("ShowMainAction", automation.Action) - -IsActiveCondition = display_menu_base_ns.class_( - "IsActiveCondition", automation.Condition -) MenuItemType = display_menu_base_ns.enum("MenuItemType") @@ -294,85 +282,23 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "display_menu.up", UpAction, MENU_ACTION_SCHEMA, synchronous=True +for _name, _call in ( + ("display_menu.up", "up()"), + ("display_menu.down", "down()"), + ("display_menu.left", "left()"), + ("display_menu.right", "right()"), + ("display_menu.enter", "enter()"), + ("display_menu.show", "show()"), + ("display_menu.hide", "hide()"), + ("display_menu.show_main", "show_main()"), +): + automation.register_apply_action( + _name, MENU_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + +automation.register_apply_condition( + "display_menu.is_active", MENU_ACTION_SCHEMA, "is_active()" ) -async def menu_up_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.down", DownAction, MENU_ACTION_SCHEMA, synchronous=True -) -async def menu_down_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.left", LeftAction, MENU_ACTION_SCHEMA, synchronous=True -) -async def menu_left_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.right", RightAction, MENU_ACTION_SCHEMA, synchronous=True -) -async def menu_right_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.enter", EnterAction, MENU_ACTION_SCHEMA, synchronous=True -) -async def menu_enter_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.show", ShowAction, MENU_ACTION_SCHEMA, synchronous=True -) -async def menu_show_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.hide", HideAction, MENU_ACTION_SCHEMA, synchronous=True -) -async def menu_hide_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "display_menu.show_main", - ShowMainAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) -async def menu_show_main_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_condition( - "display_menu.is_active", - IsActiveCondition, - automation.maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.use_id(DisplayMenuComponent), - } - ), -) -async def display_menu_is_active_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) async def menu_item_to_code(menu, config, parent): diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index be0044ffa4d..477a971d479 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,94 +5,6 @@ namespace esphome::display_menu_base { -template class UpAction final : public Action { - public: - explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->up(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class DownAction final : public Action { - public: - explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->down(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class LeftAction final : public Action { - public: - explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->left(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class RightAction final : public Action { - public: - explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->right(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class EnterAction final : public Action { - public: - explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->enter(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class ShowAction final : public Action { - public: - explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->show(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class HideAction final : public Action { - public: - explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->hide(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class ShowMainAction final : public Action { - public: - explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->show_main(); } - - protected: - DisplayMenuComponent *menu_; -}; -template class IsActiveCondition final : public Condition { - public: - explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} - bool check(const Ts &...x) override { return this->menu_->is_active(); } - - protected: - DisplayMenuComponent *menu_; -}; - class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index bdbf798df52..e672fb200fc 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -86,7 +86,7 @@ void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:"); - ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Receive Timeout: %" PRIu32 " ms", this->receive_timeout_ms_); ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); for (const auto &pattern : this->custom_patterns_) { diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 238fc7b21af..ec544ef3a4d 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -55,13 +55,4 @@ class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice } ds1307_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::ds1307 diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index a3ae3eb5af8..7a15283f3b7 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,16 +3,12 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] ds1307_ns = cg.esphome_ns.namespace("ds1307") DS1307Component = ds1307_ns.class_("DS1307Component", time.RealTimeClock, i2c.I2CDevice) -WriteAction = ds1307_ns.class_("WriteAction", automation.Action) -ReadAction = ds1307_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -22,46 +18,25 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x68)) -@automation.register_action( +automation.register_apply_action( "ds1307.write_time", - WriteAction, cv.Schema( { cv.GenerateID(): cv.use_id(DS1307Component), } ), - synchronous=True, + automation.ApplyCall("write_time()"), ) -async def ds1307_write_time_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]) - return var - -@automation.register_action( +automation.register_apply_action( "ds1307.read_time", - ReadAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(DS1307Component), } ), - synchronous=True, + automation.ApplyCall("read_time()"), ) -async def ds1307_read_time_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]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index a9e91de0b19..9b41ac7e0f6 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -2,7 +2,6 @@ #include -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" #include "esphome/components/sensor/sensor.h" @@ -47,27 +46,4 @@ class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { bool restore_; }; -template class BaseAction : public Action, public Parented {}; - -template class StartAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class ResetAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class RunningCondition final : public Condition, public Parented { - public: - explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} - - protected: - bool check(const Ts &...x) override { return this->parent_->is_running() == this->state_; } - bool state_; -}; - } // namespace esphome::duty_time_sensor diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 6d878a80a5e..7fd01db8360 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -1,10 +1,4 @@ -from esphome.automation import ( - Action, - Condition, - maybe_simple_id, - register_action, - register_condition, -) +from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, sensor import esphome.config_validation as cv @@ -19,8 +13,6 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CONF_LAST_TIME = "last_time" @@ -29,14 +21,6 @@ duty_time_sensor_ns = cg.esphome_ns.namespace("duty_time_sensor") DutyTimeSensor = duty_time_sensor_ns.class_( "DutyTimeSensor", sensor.Sensor, cg.PollingComponent ) -BaseAction = duty_time_sensor_ns.class_("BaseAction", Action, cg.Parented) -StartAction = duty_time_sensor_ns.class_("StartAction", BaseAction) -StopAction = duty_time_sensor_ns.class_("StopAction", BaseAction) -ResetAction = duty_time_sensor_ns.class_("ResetAction", BaseAction) -SetAction = duty_time_sensor_ns.class_("SetAction", BaseAction) -RunningCondition = duty_time_sensor_ns.class_( - "RunningCondition", Condition, cg.Parented -) CONFIG_SCHEMA = cv.All( @@ -86,76 +70,25 @@ async def to_code(config: ConfigType) -> None: # AUTOMATIONS -DUTY_TIME_ID_SCHEMA = maybe_simple_id( +DUTY_TIME_ID_SCHEMA = automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(DutyTimeSensor), } ) -@register_action( - "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True +for _name, _call in ( + ("sensor.duty_time.start", "start()"), + ("sensor.duty_time.stop", "stop()"), + ("sensor.duty_time.reset", "reset()"), +): + automation.register_apply_action( + _name, DUTY_TIME_ID_SCHEMA, automation.ApplyCall(_call) + ) + +automation.register_apply_condition( + "sensor.duty_time.is_running", DUTY_TIME_ID_SCHEMA, "is_running()" ) -async def sensor_runtime_start_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]) - return var - - -@register_action( - "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_apply_condition( + "sensor.duty_time.is_not_running", DUTY_TIME_ID_SCHEMA, "is_running() == false" ) -async def sensor_runtime_stop_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]) - return var - - -@register_action( - "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True -) -async def sensor_runtime_reset_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]) - return var - - -@register_condition( - "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA -) -async def duty_time_is_running_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@register_condition( - "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA -) -async def duty_time_is_not_running_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 639847345f2..6cd122fb96e 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType @@ -13,7 +14,6 @@ MULTI_CONF = True CONF_PWM = "pwm" CONF_DIVIDER = "divider" CONF_DAC = "dac" -CONF_CONVERSION_RATE = "conversion_rate" CONF_EMC2101_ID = "emc2101_id" diff --git a/esphome/components/emc2101/sensor/__init__.py b/esphome/components/emc2101/sensor/__init__.py index cc8901cf389..8c6d469fb82 100644 --- a/esphome/components/emc2101/sensor/__init__.py +++ b/esphome/components/emc2101/sensor/__init__.py @@ -55,22 +55,12 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_EMC2101_ID]) - var = cg.new_Pvariable(config[CONF_ID], paren) + hub = await cg.get_variable(config[CONF_EMC2101_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) await cg.register_component(var, config) - if CONF_INTERNAL_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_INTERNAL_TEMPERATURE]) - cg.add(var.set_internal_temperature_sensor(sens)) - - if CONF_EXTERNAL_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE]) - cg.add(var.set_external_temperature_sensor(sens)) - - if CONF_SPEED in config: - sens = await sensor.new_sensor(config[CONF_SPEED]) - cg.add(var.set_speed_sensor(sens)) - - if CONF_DUTY_CYCLE in config: - sens = await sensor.new_sensor(config[CONF_DUTY_CYCLE]) - cg.add(var.set_duty_cycle_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) + await sensors(CONF_SPEED, var.set_speed_sensor) + await sensors(CONF_DUTY_CYCLE, var.set_duty_cycle_sensor) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3821f3e10e2..7eae375cd6f 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,8 +11,7 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -23,9 +22,6 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) -# Action to send command to emonTx -EmonTxSendCommandAction = emontx_ns.class_("EmonTxSendCommandAction", automation.Action) - CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" @@ -139,20 +135,16 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +def _plain_literal(config: ConfigType, value: str) -> str: + return str(cg.safe_exp(value)) + + +automation.register_apply_action( "emontx.send_command", - EmonTxSendCommandAction, EMONTX_SEND_COMMAND_ACTION_SCHEMA, - synchronous=True, + # A constant is a plain literal for the const char * overload; a lambda returns a + # std::string and takes the inline overload. + automation.ApplyField( + CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal + ), ) -async def emontx_send_command_action_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]) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) - cg.add(var.set_command(template_)) - return var diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 7a1b084fe08..8efc2de7538 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -94,9 +94,9 @@ void EmonTx::dump_config() { * * @param command The command string to send (LF will be appended automatically). */ -void EmonTx::send_command(const std::string &command) { - ESP_LOGD(TAG, "Sending command to emonTx: %s", command.c_str()); - this->write_str(command.c_str()); +void EmonTx::send_command(const char *command) { + ESP_LOGD(TAG, "Sending command to emonTx: %s", command); + this->write_str(command); this->write_byte('\n'); } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 6db197a78c5..c56005e7ad8 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/automation.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" #include "esphome/components/uart/uart.h" @@ -39,7 +38,8 @@ class EmonTx final : public Component, public uart::UARTDevice { template void add_on_data_callback(F &&callback) { this->data_callbacks_.add(std::forward(callback)); } // Send command to emonTx via UART - void send_command(const std::string &command); + void send_command(const char *command); + void send_command(const std::string &command) { this->send_command(command.c_str()); } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -58,12 +58,4 @@ class EmonTx final : public Component, public uart::UARTDevice { std::array buffer_{}; }; -// Action to send command to emonTx -template class EmonTxSendCommandAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, command) - - void play(const Ts &...x) override { this->parent_->send_command(this->command_.value(x...)); } -}; - } // namespace esphome::emontx diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 1bdfb0c0a6b..6da0d10af90 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -63,15 +63,10 @@ async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_co2(sens)) - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc(sens)) - if aqi_config := config.get(CONF_AQI): - sens = await sensor.new_sensor(aqi_config) - cg.add(var.set_aqi(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) + await sensors(CONF_AQI, var.set_aqi) if compensation_config := config.get(CONF_COMPENSATION): sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 7b7c48c0b0f..0240aa5cccf 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -16,6 +16,28 @@ namespace esphome::epaper_spi { /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; +/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); +} + +/** Map RGB color to a single monochrome bit + * + * @param color RGB color to convert from + * @return 1 = white, 0 = black + */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) + * + * @param color RGB color to convert from + * @return Gray level: 0 = black, 3 = white + */ +constexpr uint8_t color_to_gray4(Color color) { + const uint8_t level = (uint8_t) ((rec601_luma(color) + 32u) >> 6); // quantize 0..255 to 0..3, rounded + return level > 3 ? 3 : level; +} + /** Map RGB color to discrete BWYR hex 4 color key * * @tparam NATIVE_COLOR Type of native hardware color values @@ -25,7 +47,6 @@ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; * @param hw_yellow Native value for yellow * @param hw_red Native value for red * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow, @@ -38,11 +59,7 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { // It's a shade of gray. Map to BLACK or WHITE. - // We split the luminance at the halfway point (382 = (255*3)/2) - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } // --- Step 2: Check for Primary/Secondary Colors --- @@ -96,7 +113,6 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C * @param hw_green Native value for green * @param hw_blue Native value for blue * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, @@ -105,10 +121,7 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } const bool r_on = (color.r > 128); @@ -158,7 +171,6 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV * @param hw_blue Native value for blue * @param hw_orange Native value for orange * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, @@ -167,10 +179,7 @@ constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATI const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } const bool r_on = (color.r > 128); diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index e9da924de57..3fa504539be 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -1,7 +1,9 @@ +from collections.abc import Callable import importlib import pkgutil +from typing import Any -from esphome import core, pins +from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation @@ -54,6 +56,12 @@ EPaperBase = epaper_spi_ns.class_( ) Transform = epaper_spi_ns.enum("Transform") +automation.register_apply_action( + "epaper_spi.full_update_next", + automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}), + automation.ApplyCall("request_full_update()"), +) + # Import all models dynamically from the models package for module_info in pkgutil.iter_modules(models.__path__): importlib.import_module(f".models.{module_info.name}", package=__package__) @@ -70,6 +78,23 @@ DIMENSION_SCHEMA = cv.Schema( TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} +def _full_update_every_validator( + model: models.EpaperModel, +) -> Callable[[Any], int]: + if model.get_default("partial_update"): + return cv.int_range(1, 255) + + def validate(value: Any) -> int: + value = cv.int_range(1, 255)(value) + if value != 1: + raise cv.Invalid( + f"{model.name} does not support partial update; full_update_every must be 1" + ) + return value + + return validate + + def model_schema(config): model = MODELS[config[CONF_MODEL]] class_name = epaper_spi_ns.class_(model.class_name, EPaperBase) @@ -96,7 +121,9 @@ def model_schema(config): cv.Required(CONF_MIRROR_Y): cv.boolean, } ), - cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255), + cv.Optional( + CONF_FULL_UPDATE_EVERY, default=1 + ): _full_update_every_validator(model), model.option(CONF_BUSY_PIN): pins.gpio_input_pin_schema, model.option(CONF_CS_PIN): pins.gpio_output_pin_schema, model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema, @@ -132,8 +159,15 @@ def customise_schema(config): extra=cv.ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() config = model_schema(config)(config) + config = model.validate_config(config) width, height = model.get_dimensions(config) + if width % (width_multiple := model.get_default("width_multiple", 1)): + raise cv.Invalid( + f"{model.name} requires a width that is a multiple of {width_multiple}", + path=[CONF_DIMENSIONS], + ) display.add_metadata( config[CONF_ID], width, diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3214f932bfb..1fab038c283 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -196,6 +196,15 @@ void EPaperBase::process_state_() { break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda + if (this->full_update_requested_) { + // Refresh the whole panel even if nothing was drawn + this->full_update_requested_ = false; + this->update_count_ = 0; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { this->set_state_(EPaperState::IDLE); return; @@ -299,7 +308,7 @@ bool EPaperBase::initialise(bool partial) { * @return false if the coordinates are out of bounds */ bool EPaperBase::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; if (this->effective_transform_ & SWAP_XY) std::swap(x, y); @@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { return; const size_t byte_position = y * this->row_width_ + x / 8; const uint8_t bit_position = x % 8; - const uint8_t pixel_bit = 0x80 >> bit_position; + const uint8_t pixel_bit = 0x80u >> bit_position; const auto original = this->buffer_[byte_position]; - if ((color_to_bit(color) == 0)) { + if (color_to_mono(color) == 0) { this->buffer_[byte_position] = original & ~pixel_bit; } else { this->buffer_[byte_position] = original | pixel_bit; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 8e2fd78e621..0040c867b20 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -1,5 +1,6 @@ #pragma once +#include "colorconv.h" #include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" @@ -80,15 +81,7 @@ class EPaperBase : public Display, DisplayType get_display_type() override { return this->display_type_; }; - // Default implementations for monochrome displays - static uint8_t color_to_bit(Color color) { - // It's always a shade of gray. Map to BLACK or WHITE. - // We split the luminance at a suitable point - if ((color.r + color.g + color.b) >= 382) { - return 1; - } - return 0; - } + // Default implementation for monochrome displays void fill(Color color) override { // If clipping is active, fall back to base implementation if (this->get_clipping().is_set()) { @@ -96,7 +89,7 @@ class EPaperBase : public Display, return; } - auto pixel_color = color_to_bit(color) ? 0xFF : 0x00; + auto pixel_color = color_to_mono(color) ? 0xFF : 0x00; // We store 8 pixels per byte this->buffer_.fill(pixel_color); @@ -114,6 +107,8 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + // Make the next update a full one. Applied when that update starts, so one in progress is not affected. + void request_full_update() { this->full_update_requested_ = true; } protected: int get_height_internal() override { return this->height_; }; @@ -185,6 +180,7 @@ class EPaperBase : public Display, uint8_t transform_{}; uint8_t effective_transform_{}; uint8_t update_count_{}; + bool full_update_requested_{}; // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index f44b59e803e..d0740595fc5 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -9,8 +9,8 @@ namespace esphome::epaper_spi { class EPaperMono : public EPaperBase { public: EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) { this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up } diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp new file mode 100644 index 00000000000..e845a5d5858 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -0,0 +1,101 @@ +#include "epaper_spi_ssd1677.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.ssd1677"; + +void EPaperSSD1677::setup() { + EPaperMono::setup(); + if (!this->is_failed()) + this->init_comparison_frame_(); +} + +void EPaperSSD1677::init_comparison_frame_() { + if (!this->is_using_partial_update_()) + return; + if (!this->sent_.init(this->plane_row_length_() * this->height_)) { + ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas"); + } +} + +void EPaperSSD1677::plane_row(size_t y, uint8_t *out) { + const size_t row_length = this->plane_row_length_(); + const size_t data_idx = y * row_length; + for (size_t i = 0; i != row_length; i++) + out[i] = this->buffer_[data_idx + i]; +} + +// Nothing a partial update needs is kept in controller RAM any more, so skip the reset for those. +bool EPaperSSD1677::reset() { + if (this->update_count_ != 0 && this->sent_.is_valid()) + return true; + return EPaperMono::reset(); +} + +// The window always covers the whole panel, so each plane is the frame's bytes in order. Where +// those bytes are already stored as the plane needs them (the comparison frame, and a 1-bit +// buffer) they are written straight from the buffer, as many at a time as the time slice allows; +// otherwise they are built a row at a time by plane_row(). +bool HOT EPaperSSD1677::transfer_data() { + if (!this->sent_.is_valid()) + return EPaperMono::transfer_data(); + + const auto start_time = millis(); + if (this->current_data_index_ == 0) { + if (this->plane_ == 0) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } + this->set_window(); + this->command(this->plane_ == 0 ? 0x26 : 0x24); + } + // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after + // boot): send the new frame to both planes. + const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; + const bool direct = send_copy || this->buffer_is_plane(); + const auto &source = send_copy ? this->sent_ : this->buffer_; + const size_t row_length = this->plane_row_length_(); + const size_t plane_length = row_length * this->height_; + // Roughly what the bus moves in one time slice, so a slice is not overrun by much + const size_t max_chunk = std::max(this->data_rate_ / 8000 * MAX_TRANSFER_TIME, MAX_TRANSFER_SIZE); + SmallBufferWithHeapFallback<128> row_alloc(direct ? 0 : row_length); + this->start_data_(); + while (this->current_data_index_ != plane_length) { + size_t length; + const uint8_t *data; + if (direct) { + data = source.get_span(this->current_data_index_, length); + length = std::min(length, max_chunk); + } else { + // Always at the start of a row here, since this path sends whole rows only + this->plane_row(this->current_data_index_ / row_length, row_alloc.get()); + data = row_alloc.get(); + length = row_length; + } + this->write_array(data, length); + if (this->plane_ == 1) + this->sent_.write(this->current_data_index_, data, length); + this->current_data_index_ += length; + if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + this->disable(); + this->current_data_index_ = 0; + if (this->plane_ == 0) { + this->plane_ = 1; + return false; + } + this->plane_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h new file mode 100644 index 00000000000..ddf3d4e0892 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -0,0 +1,49 @@ +#pragma once + +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome SSD1677 with partial refreshes that leave unchanged pixels alone. + * + * A partial refresh drives each pixel from the pair (RAM 0x26 = the image on the panel, + * RAM 0x24 = the new image) across the whole panel; the RAM window only scopes a write. + * EPaperMono writes 0x26 once and afterwards only the changed window of 0x24, which relies on + * the controller's RAM being unchanged from one update to the next. On this controller it is not: + * the hardware reset at the start of each update loses it, and even without resets, keeping 0x26 + * in step one window at a time left unchanged areas alternating between older frames. Either way + * unchanged pixels get driven on every partial and wash out. + * + * So before every refresh this class writes both planes over the whole panel: 0x26 from a copy of + * the frame last sent, 0x24 from the buffer. GxEPD2 likewise rewrites both planes after each + * partial on this controller. The copy is taken as the data goes out, not from the buffer, which + * may already hold the next frame by the time the refresh completes. + */ +class EPaperSSD1677 : public EPaperMono { + public: + EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) + : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {} + + void setup() override; + + protected: + // Allocates the comparison frame when partial updates are enabled. Separate from setup() so it + // can run without a bus. + void init_comparison_frame_(); + // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. + size_t plane_row_length_() const { return (this->width_ + 7) / 8; } + // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. + // A subclass with a deeper buffer returns false and overrides plane_row(). + virtual bool buffer_is_plane() const { return true; } + // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. + virtual void plane_row(size_t y, uint8_t *out); + bool reset() override; + bool transfer_data() override; + + split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows + uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24 +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp new file mode 100644 index 00000000000..18fbb016f3b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp @@ -0,0 +1,129 @@ +#include "epaper_spi_ssd1677_gray4.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.ssd1677_gray4"; + +// Combine two source bytes (4 pixels each, 2 bits per pixel, most significant pixel first) into +// one plane byte covering the same 8 pixels (1 bit per pixel), choosing high or low bit +static uint8_t plane_byte(uint8_t first, uint8_t second, bool high_bit) { + uint8_t out = 0; + for (const uint8_t src : {first, second}) { + for (uint8_t shift = 6;; shift -= 2) { + const uint8_t level = (src >> shift) & 0x03; + out = (uint8_t) ((out << 1) | (high_bit ? (level >> 1) : (level & 1))); + if (shift == 0) + break; + } + } + return out; +} + +void EPaperSSD1677Gray4::fill(Color color) { + if (this->get_clipping().is_set()) { + // Falls back to the generic per-pixel implementation for the clipped rectangle. + EPaperBase::fill(color); + return; + } + const uint8_t level = color_to_gray4(color); + this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6))); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + const uint8_t level = color_to_gray4(color); + const size_t byte_position = (size_t) y * this->row_width_ + x / 4; + const uint8_t shift = (uint8_t) (6 - 2 * (x % 4)); // most significant pixel first + const uint8_t original = this->buffer_[byte_position]; + this->buffer_[byte_position] = (uint8_t) ((original & ~(0x03 << shift)) | (level << shift)); +} + +// A partial update reduces each pixel to its high bit: levels 2 and 3 are light, 0 and 1 dark. +void EPaperSSD1677Gray4::plane_row(size_t y, uint8_t *out) { + const size_t src_row = y * this->row_width_; + for (size_t i = 0; i != this->plane_row_length_(); i++) + out[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], true); +} + +// the high bit of every pixel's level goes to the new (bw) plane +// (0x24), the low bit to the old (red) plane (0x26) +bool HOT EPaperSSD1677Gray4::transfer_data() { + if (this->is_partial_push_()) + return EPaperSSD1677::transfer_data(); + + auto start_time = millis(); + const bool first_pass = this->send_red_; + if (this->current_data_index_ == 0) { + if (first_pass) { + // With partial updates enabled the window follows the changed area, but the four-level + // refresh drives every pixel from both planes, and the reset before it does not keep RAM. + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + this->set_window(); + } + this->command(first_pass ? 0x24 : 0x26); + this->current_data_index_ = this->y_low_; + } + const size_t plane_row_length = (this->x_high_ - this->x_low_) / 8; + // Stack-backed for every panel width in practice; only a custom `dimensions:` far wider than any + // supported panel would fall back to the heap. + SmallBufferWithHeapFallback<128> bytes_to_send_alloc(plane_row_length); + uint8_t *bytes_to_send = bytes_to_send_alloc.get(); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", plane_row_length, this->current_data_index_, + (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4; + for (size_t i = 0; i != plane_row_length; i++) { + const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass); + // The OTP grayscale waveform treats data as inverted relative to monochrome + bytes_to_send[i] = (uint8_t) ~plane; + // What the next partial update compares against: the high bits, as a black-and-white frame. + if (first_pass && this->sent_.is_valid()) + this->sent_[this->current_data_index_ * plane_row_length + i] = plane; + } + ++this->current_data_index_; + this->write_array(bytes_to_send, plane_row_length); + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + + this->disable(); + this->current_data_index_ = 0; + if (first_pass) { + this->send_red_ = false; + return false; + } + this->send_red_ = true; + return true; +} + +void EPaperSSD1677Gray4::refresh_screen(bool partial) { + if (this->is_partial_push_()) { + ESP_LOGV(TAG, "Black-and-white partial refresh"); + // The border follows the LUT selected in 0x3C. The model's setting (sent with the init + // sequence) picks the LUT that is white under the four-level waveform's inverted data; under + // the black-and-white waveform that LUT drives black and the border darkens, so use LUT1. + this->cmd_data(0x3C, {0x01}); + EPaperSSD1677::refresh_screen(true); + return; + } + ESP_LOGV(TAG, "Four-level refresh"); + this->cmd_data(0x1A, {0x67, 0x00}); // force temperature by OTP + this->cmd_data(0x22, {0xD7}); // four-level update sequence, panel's OTP waveform + this->command(0x20); // master activation +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h new file mode 100644 index 00000000000..47436d2db1d --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -0,0 +1,49 @@ +#pragma once + +#include "colorconv.h" +#include "epaper_spi_ssd1677.h" + +namespace esphome::epaper_spi { + +/** + * Four-level grayscale for SSD1677 panels. + * + * The SSD1677 has two independent 1-bit RAM planes, normally used for a + * black/white and a red plane. This class writes image data to both, + * splitting each pixel's 2-bit gray level across them, and triggers the + * panel's own OTP grayscale waveform instead of the normal monochrome update + * sequence. No custom LUT upload needed for any currently supported panel, + * the OTP waveform is used instead. + * + * The framebuffer therefore packs 2 bits per pixel (4 per byte, most + * significant pixel first) instead of EPaperMono's 1 bit. + * + * The grayscale waveform has no partial form: master activation redraws the whole panel + * regardless of the RAM window. So a full update is a four-level refresh, and when partial updates + * are enabled (full_update_every > 1, which the model only allows on explicit request) a partial + * update is EPaperSSD1677's black-and-white one, each pixel reduced to light or dark. The partial + * waveform also drives unchanged pixels towards black or white, so from the first partial update + * the whole panel loses its gray levels until the next full update. + */ +class EPaperSSD1677Gray4 : public EPaperSSD1677 { + public: + EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) { + this->row_width_ = (width + 3) / 4; // 4 pixels per byte + this->buffer_length_ = (size_t) this->row_width_ * height; + } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + // A partial update if partial updates are enabled and this is not a full one. + bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } + bool buffer_is_plane() const override { return false; } + void plane_row(size_t y, uint8_t *out) override; + void refresh_screen(bool partial) override; + bool transfer_data() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 34e65061f31..fbedd6371d1 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,7 +2,9 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.core import CORE from esphome.cpp_generator import MockObj +from esphome.types import ConfigType class EpaperModel: @@ -48,6 +50,16 @@ class EpaperModel: """ return {} + def validate_config(self, config: ConfigType) -> ConfigType: + """ + Validate the configuration as a whole, once the schema has been applied. + The base implementation accepts it unchanged; specific models override this for + rules that span several options. + :param config: The validated configuration + :return: The configuration, possibly updated + """ + return config + async def to_code(self, var: MockObj, config: dict) -> dict: """ Generate model-specific code for the options added by add_options(). @@ -89,3 +101,26 @@ class EpaperModel: defaults = self.defaults.copy() defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) + + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a0457c5812a..ca824dccd4d 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -10,7 +10,9 @@ from . import EpaperModel class JD79660(EpaperModel): def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs): - super().__init__(name, class_name, **kwargs) + # Only a fast_update sequence lets the driver do anything but a full refresh + kwargs.setdefault("partial_update", fast_update is not None) + super().__init__(name, class_name=class_name, **kwargs) self.fast_update = fast_update def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required: diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index 13f10350457..2249529611e 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -1,12 +1,56 @@ -from esphome.const import CONF_DATA_RATE +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_FULL_UPDATE_EVERY +from esphome.types import ConfigType from . import EpaperModel +CONF_BORDER_WAVEFORM = "border_waveform" +CONF_MONOCHROME_PARTIAL_UPDATES = "monochrome_partial_updates" + +# partial_update value for models whose partial updates are black and white only +MONOCHROME = "monochrome" + class SSD1677(EpaperModel): - def __init__(self, name, class_name="EPaperMono", data_rate="20MHz", **defaults): + def __init__( + self, + name: str, + class_name: str = "EPaperSSD1677", + data_rate: str = "20MHz", + border_waveform: int = 0x01, + **defaults: Any, + ) -> None: defaults[CONF_DATA_RATE] = data_rate - super().__init__(name, class_name, **defaults) + defaults[CONF_BORDER_WAVEFORM] = border_waveform + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) + + def get_config_options(self) -> dict: + options = { + self.option(CONF_BORDER_WAVEFORM): cv.hex_uint8_t, + } + if self.get_default("partial_update") == MONOCHROME: + options[cv.Optional(CONF_MONOCHROME_PARTIAL_UPDATES, default=False)] = ( + cv.boolean + ) + return options + + def validate_config(self, config: ConfigType) -> ConfigType: + if ( + self.get_default("partial_update") == MONOCHROME + and config[CONF_FULL_UPDATE_EVERY] > 1 + and not config[CONF_MONOCHROME_PARTIAL_UPDATES] + ): + raise cv.Invalid( + f"{self.name} can only update partially in black and white, and a partial " + "update reduces the whole panel to black and white until the next full " + f"update. Set '{CONF_MONOCHROME_PARTIAL_UPDATES}: true' to accept this, " + "or leave full_update_every at 1", + path=[CONF_FULL_UPDATE_EVERY], + ) + return config # fmt: off def get_init_sequence(self, config: dict): @@ -15,13 +59,14 @@ class SSD1677(EpaperModel): (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) - (0x3C, 0x01), # Set border waveform + (0x3C, config[CONF_BORDER_WAVEFORM]), # Set border waveform (0x11, 3), # Set transform ) ssd1677 = SSD1677("ssd1677") + wave_4_26 = ssd1677.extend( "waveshare-4.26in", width=800, @@ -52,7 +97,8 @@ ssd1677.extend( mirror_x=True, ) -ssd1677.extend( +# Sticky - monochrome version +seeed_sticky = ssd1677.extend( "seeed-reterminal-sticky", width=800, height=480, @@ -63,4 +109,15 @@ ssd1677.extend( reset_pin=17, busy_pin=18, data_rate="10MHz", + requires={"psram"}, +) + +# Sticky - 4 level grayscale; partial updates only in black and white, on request +seeed_sticky.extend( + "seeed-reterminal-sticky-gray4", + class_name="EPaperSSD1677Gray4", + border_waveform=0x00, + partial_update=MONOCHROME, + # each plane byte is built from two whole buffer bytes + width_multiple=8, ) diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index 983f5bb382e..b43168a336e 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -6,7 +6,8 @@ from . import EpaperModel class SSD1683(EpaperModel): def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): defaults[CONF_DATA_RATE] = data_rate - super().__init__(name, class_name, **defaults) + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index bea133c3282..91c649808a8 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -32,7 +32,8 @@ class UC8179(EpaperModel): **defaults: Any, ) -> None: defaults.setdefault(CONF_DATA_RATE, data_rate) - super().__init__(name, class_name, **defaults) + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) def get_init_sequence(self, config: dict) -> tuple: """Generate the initialization sequence for UC8179 mono displays. diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index 74a288977d5..aecda723643 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -6,8 +6,12 @@ from . import EpaperModel class WaveshareModel(EpaperModel): - def __init__(self, name, lut, lut_partial=None, **defaults): - super().__init__(name, "EpaperWaveshare", **defaults) + def __init__( + self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults + ): + # A partial LUT is what lets EpaperWaveshare do partial refresh + defaults.setdefault("partial_update", lut_partial is not None) + super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 892b67b270a..5afc22aec4f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); // Configure mic 3 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); // Configure mic 4 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 00f95e1ecdc..117ad7d2434 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,7 +11,10 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg -from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.components.const import ( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, + CONF_IGNORE_NOT_FOUND, +) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -111,6 +114,8 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_NVS_CACHE_IN_PSRAM = "nvs_cache_in_psram" +CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_NVS_ENCRYPTION = "nvs_encryption" @@ -189,6 +194,13 @@ PSRAM_XIP_VARIANTS = { VARIANT_ESP32S31, } +# Variants whose ROM exports a full-format vsnprintf but no vasprintf +# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only +# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp. +# The other variants either export both (classic ESP32, nano-format only) or +# neither, so the engine is already in the image and the wrap saves nothing. +ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} + # NVS encryption (HMAC peripheral scheme) is only available on variants that # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral @@ -254,7 +266,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back - "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds + "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back @@ -416,6 +428,7 @@ ARDUINO_DISABLED_LIBRARIES: frozenset[str] = frozenset( "Hash", "HTTPClient", "HTTPUpdate", + "HTTPUpdateServer", "Insights", "LittleFS", "Matter", @@ -457,6 +470,20 @@ ESP32_CHIP_REVISIONS = { "3.1": "CONFIG_ESP32_REV_MIN_3_1", } +# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM +# and only the one matching the flash ID is ever used +ESP32_FLASH_CHIPS = { + "gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", + "issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP", + "mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP", + "winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP", + "boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP", + "th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP", + "mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", +} +FLASH_CHIP_GENERIC = "generic" +FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only + # Socket limit configuration for ESP-IDF # ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10 DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default @@ -909,14 +936,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 11), - "latest": cv.Version(3, 3, 11), - "dev": cv.Version(3, 3, 11), + "recommended": cv.Version(3, 3, 12), + "latest": cv.Version(3, 3, 12), + "dev": cv.Version(3, 3, 12), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 12): cv.Version(55, 3, 312), cv.Version(3, 3, 11): cv.Version(55, 3, 311), cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), @@ -941,6 +969,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 12): cv.Version(5, 5, 5), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -976,7 +1005,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 5): cv.Version(55, 3, 311), + cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), @@ -997,8 +1026,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 311), - "latest": cv.Version(55, 3, 311), + "recommended": cv.Version(55, 3, 312), + "latest": cv.Version(55, 3, 312), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } @@ -1512,6 +1541,13 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION], ) ) + if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi": + errs.append( + cv.Invalid( + f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FLASH_MODE], + ) + ) if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]: errs.append( cv.Invalid( @@ -1519,6 +1555,25 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM], ) ) + if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None: + opi = flash_chip == FLASH_CHIP_OPI + if opi and config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) + elif opi != (config.get(CONF_FLASH_MODE) == "opi"): + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'" + if opi + else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match " + f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32P4 and config.get(CONF_ENGINEERING_SAMPLE) is not None @@ -1544,6 +1599,29 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], ) ) + if advanced.get(CONF_NVS_CACHE_IN_PSRAM): + psram_conf = full_config.get(PSRAM_DOMAIN) + if ( + psram_conf is None + or psram_conf[CONF_DISABLED] + or psram_conf[CONF_IGNORE_NOT_FOUND] + ): + errs.append( + cv.Invalid( + f"'{CONF_NVS_CACHE_IN_PSRAM}' requires PSRAM with 'ignore_not_found: false'", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_CACHE_IN_PSRAM], + ) + ) + if ( + advanced.get(CONF_NVS_ENCRYPTION) is not None + or conf_fw[CONF_SDKCONFIG_OPTIONS].get("CONFIG_NVS_ENCRYPTION") == "y" + ): + errs.append( + cv.Invalid( + f"'{CONF_NVS_CACHE_IN_PSRAM}' cannot be used with NVS encryption; the keys must stay in internal RAM", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_CACHE_IN_PSRAM], + ) + ) final_validate_pins(full_config) @@ -1732,6 +1810,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" +CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" @@ -1746,6 +1826,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" +KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" @@ -1830,6 +1912,30 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_tls_server() -> None: + """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + + Call this from components that accept TLS connections (OpenThread's DTLS + commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from + being selected. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + + +def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: + """Mark TLS features disabled by ``disable_mbedtls_tls_extras`` as required. + + ``options`` names the entries of ``MBEDTLS_TLS_EXTRA_OPTIONS`` to keep; + omit it to keep all of them. Call this from components that need AES-CCM, + deterministic ECDSA signing, static RSA/ECDH key exchange, TLS + renegotiation or session tickets, or that run a TLS client against + servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied + sdkconfig_options value is never overridden either. + """ + required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + + def require_mbedtls_sha512() -> None: """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. @@ -1929,6 +2035,9 @@ FRAMEWORK_SCHEMA = cv.Schema( *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, + cv.Optional(CONF_FLASH_CHIP): cv.one_of( + FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True + ), # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -1953,6 +2062,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_RINGBUF_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_HEAP_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_NVS_CACHE_IN_PSRAM): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), @@ -1987,6 +2097,8 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, @@ -2277,6 +2389,20 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _apply_nvs_cache_in_psram(explicit: bool) -> None: + """Keep the NVS cache in PSRAM unless NVS encryption is on, however it was enabled.""" + # The encrypted partition object holds the derived keys, which must stay in internal RAM + if is_idf_sdkconfig_option_enabled("CONFIG_NVS_ENCRYPTION"): + if explicit: + _LOGGER.warning( + "%s ignored: NVS encryption keeps the NVS cache in internal RAM", + CONF_NVS_CACHE_IN_PSRAM, + ) + return + set_idf_sdkconfig_default("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM", True) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_certificate_bundle_sdkconfig() -> None: """Enable the mbedTLS certificate bundle only when something asked for it. @@ -2302,6 +2428,69 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None: set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) +# TLS features an HTTPS/MQTT client talking to a modern server never +# negotiates. Static RSA and static ECDH key exchange have no forward secrecy +# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables +# session tickets, AES-CCM ciphersuites are not offered by web servers, and +# deterministic ECDSA only matters when signing with a private key. Together +# they cost ~10 KB of flash whenever TLS is linked (http_request, mqtt). +# wpa_supplicant's EAP client is a second TLS client that talks to RADIUS +# servers ESPHome cannot vet, and a failed EAP handshake leaves the device +# off the network, so the wifi component re-enables all of these when eap is +# configured. +# The EC public key parsing extras stay enabled: they decide whether a peer +# certificate with a compressed point or explicit curve parameters parses, +# which no component can know ahead of time. +MBEDTLS_TLS_EXTRA_OPTIONS = ( + "CONFIG_MBEDTLS_KEY_EXCHANGE_RSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA", + "CONFIG_MBEDTLS_SSL_RENEGOTIATION", + "CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_CCM_C", + "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC", +) + +# Members of the mbedTLS "TLS Protocol Role" Kconfig choice. Setting one +# member is only valid when the user has not already chosen another. +MBEDTLS_TLS_ROLE_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_DISABLED", +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_tls_sdkconfig( + disable_tls_server: bool, disable_tls_extras: bool +) -> None: + """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. + + Runs at FINAL priority so every require_mbedtls_tls_server() and + require_mbedtls_tls_extras() call has happened. Only the server-side + handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS + connections, but OpenThread's DTLS commissioner does. A user-supplied + sdkconfig_options value always wins; for the TLS role choice, any member + the user set leaves the whole choice alone so the pair cannot conflict. + """ + data = CORE.data[KEY_ESP32] + sdkconfig = data[KEY_SDKCONFIG_OPTIONS] + if ( + disable_tls_server + and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) + and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + ): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) + if disable_tls_extras: + required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + for option in MBEDTLS_TLS_EXTRA_OPTIONS: + if option not in required: + set_idf_sdkconfig_default(option, False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. @@ -2509,6 +2698,13 @@ async def to_code(config): # NVS finds stored preferences by key, so preference key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") + # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and + # IDF's noflash placement puts the flash driver's copies in DRAM. The + # basename keeps the panic output useful at a fraction of the size. + # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. + if idf_version() >= cv.Version(5, 1, 0): + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -2566,6 +2762,17 @@ async def to_code(config): else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # esp_http_client calls vasprintf, which on the ESP32-C6 is the only + # reference to newlib's full printf engine (~20 KB: _svfprintf_r, + # _dtoa_r and their helpers); every other caller resolves to the + # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed + # because libsrc.a is scanned before the IDF libraries that + # reference the symbol, so the stub would otherwise never be pulled + # from the archive. + if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: + cg.add_define("USE_ESP32_VASPRINTF_STUB") + cg.add_build_flag("-Wl,--wrap=vasprintf") + cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -2614,6 +2821,8 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True ) + # the opi mode choice only exists once octal flash is enabled + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi") if flash_frequency := config.get(CONF_FLASH_FREQUENCY): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True @@ -2628,6 +2837,11 @@ async def to_code(config): config.get(CONF_ENGINEERING_SAMPLE, False), ) + # ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not + # understand %zu or %lld and crashes on any %s that follows one. + if variant == VARIANT_ESP32C2: + add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False) + # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, # and for PSRAM users saves significant IRAM by keeping C library functions in ROM. @@ -2638,6 +2852,11 @@ async def to_code(config): add_idf_sdkconfig_option(flag, rev == min_rev) cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET") + # Keep only the flash vendor driver the board needs; the boot log names it + if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None: + for chip, flag in ESP32_FLASH_CHIPS.items(): + add_idf_sdkconfig_option(flag, chip == flash_chip) + # Use SRAM1 region as IRAM on ESP32 (original) variant # This provides an additional 40KB of IRAM by using SRAM1 memory that was previously # reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later. @@ -2736,6 +2955,17 @@ async def to_code(config): if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) + # Imported here as psram imports this module + from esphome.components.psram import is_guaranteed as psram_is_guaranteed + + # Frees internal heap (the cache scales with the NVS partition) but slows NVS, so only + # where PSRAM is known to be fitted. Decided at FINAL so every way of enabling NVS + # encryption has been seen and a user's sdkconfig_options value wins. + # Unset means on; only an explicit true is worth a warning when it has to be dropped. + requested = advanced.get(CONF_NVS_CACHE_IN_PSRAM) + if requested is not False and psram_is_guaranteed(): + CORE.add_job(_apply_nvs_cache_in_psram, requested is True) + # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides # significant performance benefits. Our benchmarks show socket operations are @@ -2991,6 +3221,13 @@ async def to_code(config): # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) + # FINAL priority: runs after every require_mbedtls_tls_*() call + CORE.add_job( + _reconcile_mbedtls_tls_sdkconfig, + advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], + advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], + ) + # FINAL: require_*() calls can come from to_code at or below this priority, so an # inline read would be iteration-order-dependent; reconcile once after every job ran. CORE.add_job( diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index e7d8a66e7ae..a0c9809c50c 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -3,18 +3,11 @@ import esphome.codegen as cg # Re-exported for the many esp32-side users; defined in esphome.const # and esphome.espidf so the upload/logs fast path can use them without # importing this package. -from esphome.const import ( # noqa: F401 # pylint: disable=unused-import - KEY_ESP32, - KEY_FLASH_SIZE, - KEY_IDF_VERSION, - KEY_VARIANT, -) +from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import # Back compat for external components only; in-tree callers import it # from esphome.espidf directly. -from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import - variant_to_idf_target, -) +from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import KEY_BOARD = "board" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa0..b72a2777c7a 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); -void crash_handler_read_and_clear() { +// Validate the NOINIT record. Runs on every has_data() call; re-running is +// harmless and the magic is left alone so the record survives an OTA +// rollback reboot, crash_handler_clear() drops it once an API client has it. +static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data @@ -194,11 +197,12 @@ void crash_handler_read_and_clear() { s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; #endif } - // Don't clear magic here — crash data must survive OTA rollback reboots. - // Magic is cleared by crash_handler_clear() after an API client receives the data. } -bool crash_handler_has_data() { return s_crash_data_valid; } +bool crash_handler_has_data() { + read_crash_data(); + return s_crash_data_valid; +} void crash_handler_clear() { // Only clear the magic so data doesn't survive the next reboot. @@ -426,7 +430,7 @@ static void log_foreign_addresses() { // crashes again during boot, and allowing the CLI's process_stacktrace to match // and decode each address individually. void crash_handler_log() { - if (!s_crash_data_valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ece..314be80314c 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,11 +4,6 @@ namespace esphome::esp32 { -/// Read and validate crash data from NOINIT memory. -/// Does not clear the magic marker — call crash_handler_clear() after -/// the data has been delivered to an API client so it survives OTA rollback reboots. -void crash_handler_read_and_clear(); - /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); @@ -16,7 +11,7 @@ void crash_handler_log(); /// Call after the data has been delivered to an API client. void crash_handler_clear(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::esp32 diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 74665f3126a..a00c984f764 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -30,7 +30,7 @@ static const gpio_hal_context_t GPIO_HAL = {.dev = GPIO_HAL_GET_HW(GPIO_PORT_0)} bool ESP32InternalGPIOPin::isr_service_installed = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static gpio_mode_t flags_to_mode(gpio::Flags flags) { - flags = (gpio::Flags)(flags & ~(gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN)); + flags = flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); if (flags == gpio::FLAG_INPUT) return GPIO_MODE_INPUT; if (flags == gpio::FLAG_OUTPUT) diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f3..199cb89f516 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,9 +1,6 @@ #ifdef USE_ESP32 -// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set -// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/hal.h" #include @@ -45,11 +42,6 @@ void arch_restart() { } void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/vasprintf_stubs.cpp b/esphome/components/esp32/vasprintf_stubs.cpp new file mode 100644 index 00000000000..308a58ebda7 --- /dev/null +++ b/esphome/components/esp32/vasprintf_stubs.cpp @@ -0,0 +1,53 @@ +/* + * Linker wrap stub for vasprintf() on variants whose ROM exports a + * full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only). + * + * On those chips every snprintf/vsnprintf call in the image resolves to + * the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their + * helpers, ~20 KB) is not linked at all until something references a + * printf-family function the ROM lacks. esp_http_client does exactly that + * through vasprintf() in its header and auth helpers, so adding + * http_request to a build costs the whole engine on top of the HTTP and + * TLS code itself. + * + * This stub reimplements vasprintf() on top of the ROM vsnprintf(), which + * keeps the engine out of the image. It is only compiled in when codegen + * defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM + * linker script and on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB) + +#include +#include +#include + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) { + va_list ap_copy; + va_copy(ap_copy, ap); + int len = vsnprintf(nullptr, 0, fmt, ap_copy); + va_end(ap_copy); + if (len < 0) { + return len; + } + // vasprintf's contract is a malloc'd buffer the caller releases with free() + char *buf = static_cast(malloc(static_cast(len) + 1)); // NOLINT(cppcoreguidelines-no-malloc) + if (buf == nullptr) { + return -1; + } + vsnprintf(buf, static_cast(len) + 1, fmt, ap); + *strp = buf; + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 7e97111686b..ef95243bc11 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -32,8 +32,7 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, ID, TimePeriod -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv from esphome.types import ConfigType @@ -475,12 +474,17 @@ def final_validation(config: ConfigType) -> None: # Check if hosted bluetooth is being used if "esp32_hosted" in full_config: + from esphome.components.esp32_hosted import uses_esp_hosted_3x + add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False) add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True) - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) + if uses_esp_hosted_3x(): + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True) + else: + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) # Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client) has_ble_client = ( @@ -606,38 +610,27 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") -@automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_action( - "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "ble.enabled", + BLEEnabledCondition, + cv.Schema({}), ) -async def ble_enable_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True +automation.register_bare_action( + "ble.enable", + BLEEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "ble.disable", + BLEDisableAction, + cv.Schema({}), + synchronous=True, ) -async def ble_disable_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(action_id, template_arg) # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fc95760cf82..a44886b8c81 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -6,16 +6,21 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #else #include "esphome/components/watchdog/watchdog.h" #include extern "C" { #include +#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT #include #include +#endif } +#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT +#include +#endif #endif #include #include @@ -35,7 +40,7 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifdef CONFIG_BT_CONTROLLER_DISABLED // Bringing up the remote BT controller issues synchronous RPCs to the // co-processor with 5 second response timeouts, and the default task watchdog // is also 5 seconds. If the co-processor firmware does not answer (for example @@ -43,8 +48,9 @@ static const char *const TAG = "esp32_ble"; // device before the RPC could return an error, causing a boot loop. Raise the // watchdog for the duration of the bring-up so failures surface as error // returns instead. 60 seconds covers the worst case: transport reconnect -// (up to ~20s), version preflight (1s), controller init/enable (5s each) and -// the bluedroid host bring-up over the hosted HCI transport. +// (up to ~20s), version preflight (1s), the controller init retry window +// (5s of 5s RPCs), controller enable (5s) and the bluedroid host bring-up +// over the hosted HCI transport. static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; #endif @@ -83,18 +89,23 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING @@ -197,10 +208,10 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifdef CONFIG_BT_CONTROLLER_DISABLED watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); #endif -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) { // start bt controller if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_IDLE) { @@ -234,8 +245,7 @@ bool ESP32BLE::ble_setup_() { } // Fast preflight (1 second RPC timeout): verifies the co-processor answers - // RPCs at all before the 5 second timeout BT controller RPCs below, and - // before hosted_hci_bluedroid_open(), which aborts if the transport is down. + // RPCs at all before the 5 second timeout BT controller RPCs below. esp_hosted_coprocessor_fwver_t fw_ver{}; if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) { ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted " @@ -244,6 +254,19 @@ bool ESP32BLE::ble_setup_() { } ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); +#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT + esp_hosted_bt_host_stack_cfg_t bt_cfg{}; + bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID; + bt_cfg.bring_up_controller = true; + bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS; + if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) { + ESP_LOGE(TAG, + "BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); + return false; + } +#else if (esp_hosted_bt_controller_init() != ESP_OK) { ESP_LOGE(TAG, "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 @@ -268,6 +291,7 @@ bool ESP32BLE::ble_setup_() { .register_host_callback = hosted_hci_bluedroid_register_host_callback, }; esp_bluedroid_attach_hci_driver(&operations); +#endif #endif err = esp_bluedroid_init(); @@ -388,7 +412,7 @@ bool ESP32BLE::ble_setup_() { } bool ESP32BLE::ble_dismantle_() { -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifdef CONFIG_BT_CONTROLLER_DISABLED // Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); #endif @@ -411,7 +435,7 @@ bool ESP32BLE::ble_dismantle_() { ESP_LOGD(TAG, "Already deinitialized"); } -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_IDLE) { // stop bt controller if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED) { @@ -435,6 +459,11 @@ bool ESP32BLE::ble_dismantle_() { return false; } } +#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT) + if (esp_hosted_bt_host_stack_teardown() != ESP_OK) { + ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed"); + return false; + } #else if (esp_hosted_bt_controller_disable() != ESP_OK) { ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); @@ -580,7 +609,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 7d2d0438a46..fd4fb15ff69 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,8 +102,8 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; @@ -176,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index ab728f9f6fc..47e4b7ebb20 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -3,7 +3,7 @@ #ifdef USE_ESP32 -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include @@ -35,7 +35,7 @@ void ESP32BLEBeacon::dump_config() { } } *bpos = '\0'; -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d" ", TX Power: %ddBm", @@ -81,7 +81,7 @@ void ESP32BLEBeacon::on_advertise_() { ibeacon_adv_data.ibeacon_vendor.measured_power = static_cast(this->measured_power_); esp_err_t err; -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED ESP_LOGD(TAG, "Setting BLE TX power"); err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_); if (err != ESP_OK) { diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 986778de579..a7132a54652 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -5,7 +5,7 @@ #ifdef USE_ESP32 -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include @@ -47,7 +47,7 @@ class ESP32BLEBeacon final : public Component { void set_min_interval(uint16_t val) { this->min_interval_ = val; } void set_max_interval(uint16_t val) { this->max_interval_ = val; } void set_measured_power(int8_t val) { this->measured_power_ = val; } -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; } #endif void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); @@ -61,7 +61,7 @@ class ESP32BLEBeacon final : public Component { uint16_t min_interval_{}; uint16_t max_interval_{}; int8_t measured_power_{}; -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED esp_power_level_t tx_power_{}; #endif esp_ble_adv_params_t ble_adv_params_; diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e6cdde9cda6..88454f7bdbf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -72,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -117,6 +136,15 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // A registration whose event never arrived must not block this connection's release. @@ -199,7 +227,10 @@ void BLEClientBase::release_services() { #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; - esp_ble_gattc_cache_clean(this->remote_bda_); + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + esp_ble_gattc_cache_clean(this->remote_bda_); + } #endif } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index e4b9cd51005..fbd405156ae 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; void unconditional_disconnect(); void release_services(); @@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + // Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it bool services_released_{false}; // 8 bytes used, no padding @@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); /// Hook called once a connection has been fully torn down (after release_services() and - /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to release that state. `reason` is the controller reason code, or - /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d8095cd7020..9355d588596 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,6 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble +from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -36,12 +37,10 @@ CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" CONF_CHARACTERISTICS = "characteristics" -CONF_DESCRIPTION = "description" CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" CONF_INDICATE = "indicate" -CONF_MANUFACTURER = "manufacturer" CONF_MANUFACTURER_DATA = "manufacturer_data" CONF_MAX_CLIENTS = "max_clients" CONF_ON_WRITE = "on_write" @@ -597,7 +596,7 @@ async def to_code(config): cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) 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 + # find. A server that is auto-loaded purely to host a runtime service (improv_ble) stays # silent until that service asks for advertising. cg.add( var.set_advertising_required( diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 45679b9b982..e34a57cf37f 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -10,7 +10,7 @@ #include #include #include -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 7869c73cc53..e469b60e088 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -40,7 +40,7 @@ class BLEServer final : public Component, public Parented { /** 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 + * False for a server that only hosts services created at runtime (e.g. improv_ble), which * request advertising themselves for as long as they need it. */ void set_advertising_required(bool required) { this->advertising_required_ = required; } diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 906144e5fde..205acc55a53 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -39,8 +39,7 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType @@ -116,12 +115,6 @@ BLEEndOfScanTrigger = esp32_ble_tracker_ns.class_( "BLEEndOfScanTrigger", automation.Trigger.template() ) # Actions -ESP32BLEStartScanAction = esp32_ble_tracker_ns.class_( - "ESP32BLEStartScanAction", automation.Action -) -ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_( - "ESP32BLEStopScanAction", automation.Action -) def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: @@ -469,23 +462,12 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "esp32_ble_tracker.start_scan", - ESP32BLEStartScanAction, ESP32_BLE_START_SCAN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_CONTINUOUS, "set_scan_continuous", cg.bool_), + automation.ApplyCall("start_scan_if_idle()"), ) -async def esp32_ble_tracker_start_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) - cg.add(var.set_continuous(template_)) - return var ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( @@ -497,21 +479,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "esp32_ble_tracker.stop_scan", - ESP32BLEStopScanAction, ESP32_BLE_STOP_SCAN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def esp32_ble_tracker_stop_scan_action_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]) - return var async def register_ble_device( diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 541b63b2fdb..9b4eb2c3b30 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -94,30 +94,6 @@ class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction final : public Action { - public: - ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(bool, continuous) - void play(const Ts &...x) override { - this->parent_->set_scan_continuous(this->continuous_.value(x...)); - // Only call start_scan() if scanner is IDLE - // For other states (STARTING, RUNNING, STOPPING, FAILED), the normal state - // machine flow will eventually transition back to IDLE, at which point - // loop() will see scan_continuous_ and restart scanning if it is true. - if (this->parent_->get_scanner_state() == ScannerState::IDLE) { - this->parent_->start_scan(); - } - } - - protected: - ESP32BLETracker *parent_; -}; - -template class ESP32BLEStopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::esp32_ble_tracker #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5339565a324..9bb90026e3f 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -7,7 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include @@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; @@ -74,11 +79,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -218,7 +223,27 @@ void ESP32BLETracker::stop_scan() { this->stop_scan_(); } -void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } +void ESP32BLETracker::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { @@ -610,7 +635,7 @@ void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE void ESP32BLETracker::update_coex_preference_(bool force_ble) { -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED if (force_ble && !this->coex_prefer_ble_) { ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); this->coex_prefer_ble_ = true; @@ -620,7 +645,7 @@ void ESP32BLETracker::update_coex_preference_(bool force_ble) { this->coex_prefer_ble_ = false; esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default } -#endif // CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#endif // CONFIG_BT_CONTROLLER_DISABLED } #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 618444e626d..6b71480ed3e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } @@ -132,7 +135,7 @@ class ESPBTClient : public ESPBTDeviceListener { void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; } // Memory optimized layout - uint8_t app_id; // App IDs are small integers assigned sequentially + uint8_t app_id{0}; // App IDs are small integers assigned sequentially protected: /// Set state without IDLE handling - use for direct state transitions. @@ -215,6 +218,12 @@ class ESP32BLETracker final : public Component, #endif void start_scan(); + // For the start_scan action: in any other state the state machine returns to IDLE on its own + // and loop() restarts scanning when scan_continuous_ is set, so only an idle scanner starts here. + void start_scan_if_idle() { + if (this->scanner_state_ == ScannerState::IDLE) + this->start_scan(); + } void stop_scan(); void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 598fe61d464..03fdbc4de7c 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -433,25 +433,10 @@ void ESP32Camera::set_pixel_format(ESP32CameraPixelFormat format) { } } void ESP32Camera::set_jpeg_quality(uint8_t quality) { this->config_.jpeg_quality = quality; } -void ESP32Camera::set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } -void ESP32Camera::set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } -void ESP32Camera::set_contrast(int contrast) { this->contrast_ = contrast; } -void ESP32Camera::set_brightness(int brightness) { this->brightness_ = brightness; } -void ESP32Camera::set_saturation(int saturation) { this->saturation_ = saturation; } -void ESP32Camera::set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* set exposure parameters */ -void ESP32Camera::set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } -void ESP32Camera::set_aec2(bool aec2) { this->aec2_ = aec2; } -void ESP32Camera::set_ae_level(int ae_level) { this->ae_level_ = ae_level; } -void ESP32Camera::set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* set gains parameters */ -void ESP32Camera::set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } -void ESP32Camera::set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } -void ESP32Camera::set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* set white balance */ -void ESP32Camera::set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* set test mode */ -void ESP32Camera::set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* set fps */ void ESP32Camera::set_max_update_interval(uint32_t max_update_interval) { this->max_update_interval_ = max_update_interval; diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 83dab5f77a3..9ff309ad4a0 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -140,25 +140,25 @@ class ESP32Camera final : public camera::Camera { void set_pixel_format(ESP32CameraPixelFormat format); void set_frame_size(ESP32CameraFrameSize size); void set_jpeg_quality(uint8_t quality); - void set_vertical_flip(bool vertical_flip); - void set_horizontal_mirror(bool horizontal_mirror); - void set_contrast(int contrast); - void set_brightness(int brightness); - void set_saturation(int saturation); - void set_special_effect(ESP32SpecialEffect effect); + void set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } + void set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } + void set_contrast(int contrast) { this->contrast_ = contrast; } + void set_brightness(int brightness) { this->brightness_ = brightness; } + void set_saturation(int saturation) { this->saturation_ = saturation; } + void set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* -- exposure */ - void set_aec_mode(ESP32GainControlMode mode); - void set_aec2(bool aec2); - void set_ae_level(int ae_level); - void set_aec_value(uint32_t aec_value); + void set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } + void set_aec2(bool aec2) { this->aec2_ = aec2; } + void set_ae_level(int ae_level) { this->ae_level_ = ae_level; } + void set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* -- gains */ - void set_agc_mode(ESP32GainControlMode mode); - void set_agc_value(uint8_t agc_value); - void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling); + void set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } + void set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } + void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* -- white balance */ - void set_wb_mode(ESP32WhiteBalanceMode mode); + void set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* -- test */ - void set_test_pattern(bool test_pattern); + void set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* -- framerates */ void set_max_update_interval(uint32_t max_update_interval); void set_idle_update_interval(uint32_t idle_update_interval); diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index ab9455250cd..d78941ea4c8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import re from esphome import pins from esphome.components import esp32 @@ -7,15 +8,22 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, + CONF_COMPONENTS, CONF_CS_PIN, + CONF_FRAMEWORK, CONF_FREQUENCY, CONF_MISO_PIN, CONF_MOSI_PIN, + CONF_NAME, + CONF_REF, CONF_RESET_PIN, CONF_TYPE, CONF_VARIANT, + KEY_ESP32, ) +from esphome.core import CORE from esphome.cpp_generator import add_define +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] @@ -23,6 +31,9 @@ DEPENDENCIES = ["esp32"] # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] +# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5) +ESP_HOSTED_COMPONENT = "espressif/esp_hosted" + CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" CONF_CMD_PIN = "cmd_pin" @@ -37,6 +48,25 @@ CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" 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 BASE_SCHEMA = cv.Schema( { @@ -126,6 +156,34 @@ CONFIG_SCHEMA = cv.typed_schema( ) +# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x +_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$") + + +def user_esp_hosted_major() -> int | None: + """Major version of a user-pinned esp_hosted, or None without a pin.""" + try: + full_config = fv.full_config.get() + except LookupError: + full_config = CORE.config + esp32_config = full_config.get(KEY_ESP32) or {} + for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []): + if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT: + continue + ref = component.get(CONF_REF, "").strip() + if not ref: + return 2 + if match := _PINNED_MAJOR.match(ref): + return int(match.group(1)) + return 3 + return None + + +def uses_esp_hosted_3x() -> bool: + """Whether the user pinned esp_hosted 3.x.""" + return (major := user_esp_hosted_major()) is not None and major >= 3 + + def _final_validate(config: ConfigType) -> None: # The esp_hosted releases compatible with older ESP-IDF versions crash at # boot with a heap double free in the SDIO RX path (fixed in esp_hosted @@ -136,12 +194,34 @@ def _final_validate(config: ConfigType) -> None: "Remove the framework version from your configuration to use the " "recommended version, or pin a version at or above 5.3." ) + if (major := user_esp_hosted_major()) is None or major < 3: + return + if idf_ver < cv.Version(5, 5, 0): + raise cv.Invalid( + f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. " + f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework." + "components to stay on the 2.x line, or use ESP-IDF 5.5 or newer." + ) + if not config[CONF_ACTIVE_HIGH]: + raise cv.Invalid( + "esp_hosted 3.x always parks the reset line high with a low pulse, so " + "'active_high: false' cannot be expressed on it. Remove the " + f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay " + "on the 2.x line." + ) + if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1: + raise cv.Invalid( + "esp_hosted 3.x cannot be built with a 1-bit SDIO bus " + "(espressif/esp-hosted-mcu#245). Remove the " + f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay " + "on the 2.x line, or use a 4-bit bus." + ) FINAL_VALIDATE_SCHEMA = _final_validate -def _configure_sdio(config: ConfigType) -> None: +def _configure_sdio_2x(config: ConfigType) -> None: slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", @@ -183,7 +263,42 @@ def _configure_sdio(config: ConfigType) -> None: ) -def _configure_spi(config: ConfigType) -> None: +def _configure_sdio_3x(config: ConfigType) -> None: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True) + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}", + True, + ) + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_HOST_SDIO_BUS_WIDTH_{config[CONF_BUS_WIDTH]}", + True, + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CLK", config[CONF_CLK_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CMD", config[CONF_CMD_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN] + ) + if config[CONF_BUS_WIDTH] == 4: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D3", config[CONF_D3_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_CLK_KHZ", + int(config[CONF_SDIO_FREQUENCY] // 1000), + ) + + +def _configure_spi_2x(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) # SPI mode is set via per-variant choice options variant = config[CONF_VARIANT] @@ -231,11 +346,53 @@ def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) -async def to_code(config: ConfigType) -> None: - add_define("USE_ESP32_HOSTED") - transport = config[CONF_TYPE] - transport_prefix = "SDIO" if transport == "sdio" else "SPI" +def _configure_spi_3x(config: ConfigType) -> None: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_CLK_MHZ", int(config[CONF_FREQUENCY] // 1e6) + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_MOSI_GPIO", config[CONF_MOSI_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_MISO_GPIO", config[CONF_MISO_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_CLK_GPIO", config[CONF_CLK_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_CS_GPIO", config[CONF_CS_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_HANDSHAKE_GPIO", config[CONF_HANDSHAKE_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_DATA_READY_GPIO", config[CONF_DATA_READY_PIN] + ) + # Handshake and data_ready polarity + if config[CONF_HANDSHAKE_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_HIGH", True + ) + else: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_LOW", True + ) + if config[CONF_DATA_READY_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_HIGH", True + ) + else: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_LOW", True + ) + +def _configure_2x(config: ConfigType) -> None: + transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI" # Reset polarity if config[CONF_ACTIVE_HIGH]: esp32.add_idf_sdkconfig_option( @@ -255,27 +412,79 @@ async def to_code(config: ConfigType) -> None: f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT True, ) - - # Transport-specific configuration - if transport == "sdio": - _configure_sdio(config) + if config[CONF_TYPE] == "sdio": + _configure_sdio_2x(config) else: - _configure_spi(config) - + _configure_spi_2x(config) + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + # 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 # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM # mempool allocation fails at boot with `sdio_mempool_create` assert. if config[CONF_USE_PSRAM]: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) + +def _configure_3x(config: ConfigType) -> None: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN] + ) + # Co-processor variant + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}", + True, + ) + if config[CONF_TYPE] == "sdio": + _configure_sdio_3x(config) + else: + _configure_spi_3x(config) + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA_MAX_CUSTOM_MSG_HANDLERS", + _MAX_CUSTOM_MSG_HANDLERS, + ) + if config[CONF_USE_PSRAM]: + esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True) + + +async def to_code(config: ConfigType) -> None: + add_define("USE_ESP32_HOSTED") + use_3x = uses_esp_hosted_3x() + + if use_3x: + _configure_3x(config) + else: + _configure_2x(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 the peer-data Kconfig + # options (set above) 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_now_hosted.cpp includes esp_now.h, which esp_wifi provides + esp32.include_builtin_idf_component("esp_wifi") + # Library versions; this component set requires ESP-IDF 5.3 or newer, # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp new file mode 100644 index 00000000000..ad29b208feb --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -0,0 +1,467 @@ +/* + * esp_now_hosted — host-side shim implementing 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 + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_idf_version.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include // injected declarations we are now DEFINING +#include // 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(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(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(len)); + return; + } + const auto *r = reinterpret_cast(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(len)); + return; + } + const auto *e = reinterpret_cast(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(esp_timer_get_time()); + + esp_now_recv_info_t info; + info.src_addr = const_cast(e->src_addr); + info.des_addr = const_cast(e->des_addr); + info.rx_ctrl = &rx_ctrl; + cb(&info, e->data, static_cast(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(len)); + return; + } + const auto *e = reinterpret_cast(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(e->des_addr); + cb(&si, static_cast(e->status)); +#else + cb(e->des_addr, static_cast(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(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(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(g_resp_ret), n); + if (ret_len != nullptr) + *ret_len = n; + } + xSemaphoreGive(g_req_mutex); + return static_cast(status); +} + +} // namespace + +// ── The 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(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(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(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(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 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 diff --git a/esphome/components/esp32_hosted/esp_now_hosted_rpc.h b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h new file mode 100644 index 00000000000..bf68c759ee9 --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h @@ -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 +#else +#include +#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 */ diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 4eb5d1745be..c35301a6852 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -26,6 +26,8 @@ static const char *const TAG = "esp32_hosted.update"; // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +// OTA begin blocks while the coprocessor erases its partition +constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000; #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // Interval/timeout IDs (uint32_t to avoid string comparison) @@ -169,7 +171,7 @@ void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, " Mode: HTTP\n" " Source URL: %s", - this->source_url_.c_str()); + this->source_url_); #else ESP_LOGCONFIG(TAG, " Mode: Embedded\n" @@ -215,7 +217,7 @@ bool Esp32HostedUpdate::fetch_manifest_() { auto container = this->http_request_parent_->get(this->source_url_); if (container == nullptr || container->status_code != 200) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_); this->status_set_error(LOG_STR("Failed to fetch manifest")); return false; } @@ -336,7 +338,11 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size); // Begin OTA on coprocessor - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); container->end(); @@ -425,7 +431,11 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_); - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); this->status_set_error(LOG_STR("Failed to begin OTA")); @@ -472,8 +482,6 @@ void Esp32HostedUpdate::perform(bool force) { this->update_info_.has_progress = false; this->publish_state(); - watchdog::WatchdogManager watchdog(60000); - #ifdef USE_ESP32_HOSTED_HTTP_UPDATE if (!this->stream_firmware_to_coprocessor_()) #else diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 4f9d04738dd..c319852bff9 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode setters - void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_source_url(const char *url) { this->source_url_ = url; } void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } #else // Embedded mode setters @@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode members http_request::HttpRequestComponent *http_request_parent_{nullptr}; - std::string source_url_; + const char *source_url_{nullptr}; // literal from codegen std::string firmware_url_; // HTTP mode helpers diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 19dbb68f29a..e4d8dfc4928 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -5,6 +5,7 @@ import re import subprocess from typing import Any +from esphome.build_helpers.native import native_backend import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -14,6 +15,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_TOOLCHAIN, CONF_VERSION, KEY_CORE, KEY_FRAMEWORK_VERSION, @@ -21,6 +23,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_ESP8266, ThreadModel, + Toolchain, ) from esphome.core import ( CORE, @@ -37,13 +40,16 @@ from esphome.types import ConfigType from .boards import BOARDS, board_ld_script from .const import ( + BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, KEY_BOARD, + KEY_DECODE_TOOLS, KEY_ESP8266, KEY_PIN_INITIAL_STATES, + KEY_SCANF_FLOAT, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, @@ -103,6 +109,59 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) +_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO) + + +def _warn_platformio_toolchain(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for ESP8266 is deprecated and will be " + "removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the " + "default." + ) + return config + + +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 framework_release + + conf = config[CONF_FRAMEWORK] + version = cv.Version.parse(conf[CONF_VERSION]) + try: + framework_release(version) + except EsphomeError as err: + raise cv.Invalid(str(err), path=[CONF_FRAMEWORK, CONF_VERSION]) from err + # platform_version is a PlatformIO concept; drop it, warning when a + # custom pin is discarded + 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" + ) + # The native path fetches its own build; source may only be the + # PlatformIO default the schema filled in + if conf[CONF_SOURCE] != _format_framework_arduino_version(version): + raise cv.Invalid( + "'toolchain: arduino' does not support a custom framework source; " + "use 'toolchain: platformio'" + ) + if config[CONF_BOARD] not in BOARDS: + 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]]: """Binary-download entries for a built ESP8266 firmware. @@ -127,16 +186,27 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: ] +def _framework_package_version(ver: cv.Version) -> str: + """Map an Arduino core version to its PlatformIO registry package version + (3.1.2 -> 3.30102.0; the leading 3 is the package major).""" + if ver.major > 3: + raise EsphomeError( + f"Arduino core {ver} is not supported yet; " + "the newest known core series is 3.x" + ) + if ver.major < 3: + raise EsphomeError( + f"Arduino core {ver} is not supported; ESPHome requires core 3.x" + ) + return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + + def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/esp8266/Arduino/releases) version to # a PIO platformio/framework-arduinoespressif8266 value # 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 - # version bump cannot drift between the two paths. - from esphome.arduino8266.framework import framework_package_version - try: - return f"~{framework_package_version(ver)}" + return f"~{_framework_package_version(ver)}" except EsphomeError as err: # Anchor the 4.x rejection to the framework version line instead of # aborting with a bare traceback-level error @@ -147,7 +217,8 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # * New framework historically have had some regressions, especially for WiFi. # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked -# * For all constants below, update platformio.ini (in this repo) +# * For all constants below, update platformio.ini (in this repo) and +# FRAMEWORK_RELEASES in esphome/arduino8266/framework.py # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases @@ -194,7 +265,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: platform_version = value.get(CONF_PLATFORM_VERSION) if platform_version is None: if version >= cv.Version(3, 1, 0): - platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) + platform_version = _ARDUINO_4_PLATFORM_SPEC else: platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) value[CONF_PLATFORM_VERSION] = platform_version @@ -217,6 +288,10 @@ def _parse_platform_version(value: Any) -> str: 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( cv.Schema( { @@ -233,7 +308,6 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( ) -BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -250,11 +324,14 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, } ), - # Until the native toolchain lands, PlatformIO is the only backend; - # reject a --toolchain this platform cannot serve yet. - cv.require_platformio_toolchain("ESP8266"), + _resolve_toolchain, + _warn_platformio_toolchain, + _validate_native_toolchain, set_core_data, ) @@ -262,8 +339,8 @@ CONFIG_SCHEMA = cv.All( def check_rosetta() -> None: """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. - There is no native arm64 build of the xtensa-lx106 toolchain; on Apple - Silicon it runs under Rosetta 2, which macOS updates can remove. + PlatformIO's xtensa-lx106 toolchain has no arm64 build; on Apple Silicon + it runs under Rosetta 2, which macOS updates can remove. """ if not IS_MACOS or platform.machine() != "arm64": return @@ -278,9 +355,10 @@ def check_rosetta() -> None: return # arch(1) unavailable; let the build proceed if result.returncode != 0: raise EsphomeError( - "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " - "compiler that requires Rosetta 2, which is not installed on " - "this system. Install it with:\n" + "ESP8266 builds with PlatformIO on Apple Silicon Macs use an Intel " + "(x86_64) compiler that requires Rosetta 2, which is not installed " + "on this system. Either set 'toolchain: arduino' under 'esp8266:', " + "which needs no Rosetta, or install it with:\n" " softwareupdate --install-rosetta --agree-to-license" ) @@ -294,12 +372,13 @@ def _choose_ld_script(board: str) -> str: @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: + use_platformio = CORE.using_toolchain_platformio cg.add(esp8266_ns.setup_preferences()) - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - - cg.add_platformio_option("board", config[CONF_BOARD]) + if use_platformio: + cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_ESP8266") cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -315,28 +394,32 @@ async def to_code(config: ConfigType) -> None: "enabling scanf float support (~8KB flash)" ) - extra_scripts = [ - "pre:ccache.py", - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:relocate_ratetable.py", - ] - if not enable_scanf_float: - extra_scripts.append("pre:remove_float_scanf.py") - extra_scripts.append("post:post_build.py") - cg.add_platformio_option("extra_scripts", extra_scripts) + # The native generator reads the same decision (KEY_SCANF_FLOAT) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) + if use_platformio: + extra_scripts = [ + "pre:ccache.py", + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + "pre:relocate_ratetable.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] - cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO") cg.add_build_flag("-Wno-nonnull-compare") - cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) - cg.add_platformio_option( - "platform_packages", - [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], - ) + if use_platformio: + cg.add_platformio_option("framework", "arduino") + cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) + cg.add_platformio_option( + "platform_packages", + [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], + ) # Default for platformio is LWIP2_LOW_MEMORY with: # - MSS=536 @@ -363,18 +446,10 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_SERIAL1): enable_serial1() - # Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when - # out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make - # new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of - # a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3, - # which always aborts if exceptions are disabled. - # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` - cg.add_build_flag("-DNEW_OOM_ABORT") - # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h for details. Must be prepended before , so this - # uses build_src_flags with -include. + # See throw_stubs.h. Unconditional: the native build generator reads + # the same option, keeping one source of truth. cg.add_platformio_option( "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" ) @@ -404,6 +479,8 @@ async def to_code(config: ConfigType) -> None: # implementation in the Arduino ESP8266 core. 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]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -412,7 +489,7 @@ async def to_code(config: ConfigType) -> None: cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - if config[CONF_BOARD] in BOARDS: + if use_platformio and config[CONF_BOARD] in BOARDS: cg.add_platformio_option( "board_build.ldscript", _choose_ld_script(config[CONF_BOARD]) ) @@ -453,8 +530,24 @@ async def finalize_serial_config() -> None: 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_backend() + 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 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 for script in ( "post_build", @@ -511,22 +604,54 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config: ConfigType, addr: str) -> None: +def _resolve_decode_tools(config: ConfigType) -> tuple[str, str] | None: + """``(addr2line, elf)`` for this build, or None after warning why.""" + if (native_toolchain := native_backend()) 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(): + _LOGGER.warning("Cannot decode crash addresses: %s missing", path) + return None + return str(addr2line), str(elf) from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: - _LOGGER.debug("decode_pc no addr2line") + _LOGGER.warning("Cannot decode crash addresses: no addr2line or ELF in idedata") + return None + return idedata.addr2line_path, idedata.firmware_elf_path + + +def _decode_tools(config: ConfigType) -> tuple[str, str] | None: + """Resolved once per run: the tools are a property of the build, not of + the address, so a stack dump cannot repeat the failure warning.""" + data = CORE.data.setdefault(KEY_ESP8266, {}) + if KEY_DECODE_TOOLS not in data: + data[KEY_DECODE_TOOLS] = _resolve_decode_tools(config) + return data[KEY_DECODE_TOOLS] + + +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.""" + # Bulk stack words are ~150 per dump; only named registers warn + log = _LOGGER.debug if bulk else _LOGGER.warning + if (tools := _decode_tools(config)) is None: + log("Not decoded %s (no addr2line or ELF)", addr) return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + addr2line, elf = tools + command = [addr2line, "-pfiaC", "-e", elf, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + log("Could not decode crash address %s (%s)", addr, err) _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return if "?? ??:0" in translation: - # Nothing useful + log("Not decoded %s (address not in %s)", addr, elf) return translation = translation.replace(" at ??:?", "").replace(":?", "") _LOGGER.warning("Decoded %s", translation) @@ -596,6 +721,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> if backtrace_state: for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line): - _decode_pc(config, addr.group()) + _decode_pc(config, addr.group(), bulk=True) return backtrace_state diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 50f103ed2db..bf4dd1a3584 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -16,10 +16,11 @@ KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator -KEY_FLASH_MODE = "flash_mode" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" +# Crash-decode tools, resolved once per run by __init__ +KEY_DECODE_TOOLS = "decode_tools" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") @@ -73,3 +74,6 @@ def enable_serial1() -> None: enable_serial1() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True + + +BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout") diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index a85f054dfe9..66ac122f000 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -8,6 +8,8 @@ namespace esphome::esp8266 { static const char *const TAG = "esp8266"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { + flags = + flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); if (flags == gpio::FLAG_OUTPUT || flags == (gpio::FLAG_OUTPUT | gpio::FLAG_INPUT)) { return OUTPUT; } diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6e..7b36c410a25 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -4,13 +4,15 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" namespace esphome::esp8266_pwm { class ESP8266PWM final : public output::FloatOutput, public Component { public: + // User provided, not "= default": `new(p) ESP8266PWM()` would zero-fill .bss that is already zero. + ESP8266PWM() {} + void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically update frequency @@ -28,25 +30,12 @@ class ESP8266PWM final : public output::FloatOutput, public Component { protected: void write_state(float state) override; - InternalGPIOPin *pin_; - float frequency_{1000.0}; + InternalGPIOPin *pin_{nullptr}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - ESP8266PWM *parent_; -}; - } // namespace esphome::esp8266_pwm #endif diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e044..62ec1fe003b 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,8 +4,6 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] @@ -19,9 +17,12 @@ def valid_pwm_pin(value: ConfigType) -> ConfigType: esp8266_pwm_ns = cg.esphome_ns.namespace("esp8266_pwm") ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Component) -SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,28 +49,18 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) -@automation.register_action( +automation.register_apply_action( "output.esp8266_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(ESP8266PWM), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def esp8266_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index 46810d422de..23536fe3a28 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,12 +1,11 @@ from typing import Any -from esphome.automation import Action, register_action +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_VOLTAGE from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.final_validate import full_config from esphome.types import ConfigType @@ -16,7 +15,6 @@ DOMAIN = "esp_ldo" esp_ldo_ns = cg.esphome_ns.namespace("esp_ldo") EspLdo = esp_ldo_ns.class_("EspLdo", cg.Component) -AdjustAction = esp_ldo_ns.class_("AdjustAction", Action) CHANNELS = (1, 2, 3, 4) CHANNELS_INTERNAL = (1, 2) @@ -123,9 +121,8 @@ def adjusted_ldo_id(value: Any) -> ID: return value -@register_action( +automation.register_apply_action( "esp_ldo.voltage.adjust", - AdjustAction, cv.Schema( { cv.GenerateID(CONF_ID): adjusted_ldo_id, @@ -134,16 +131,5 @@ def adjusted_ldo_id(value: Any) -> ID: ), } ), - synchronous=True, + automation.ApplyField(CONF_VOLTAGE, "adjust_voltage", cg.float_), ) -async def ldo_voltage_adjust_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - template_ = await cg.templatable(config[CONF_VOLTAGE], args, cg.float_) - cg.add(var.set_voltage(template_)) - return var diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index 0451c338dd1..0f0131d4d5a 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -1,7 +1,6 @@ #pragma once #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esp_ldo_regulator.h" namespace esphome::esp_ldo { @@ -27,18 +26,6 @@ class EspLdo final : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction final : public Action { - public: - explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} - - TEMPLATABLE_VALUE(float, voltage) - - void play(const Ts &...x) override { this->ldo_->adjust_voltage(this->voltage_.value(x...)); } - - protected: - EspLdo *ldo_; -}; - } // namespace esphome::esp_ldo #endif // USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260c..e14f5e7e86e 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -2,7 +2,7 @@ import logging import esphome.codegen as cg from esphome.components.noise import ( - encryption_schema, + ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, ) @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv from esphome.types import ConfigType @@ -166,9 +167,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: 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 + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf @@ -223,15 +232,38 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - encryption_conf[CONF_KEY] = api_key +# Uploader side options live only on the ota block; the api block keeps the +# shared schema +_ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( + { + cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, + } +) + + +def _encryption_schema(config: ConfigType | None) -> ConfigType: + # Only a bare `encryption:` block is keyless; `false` or a list must fail + return _ENCRYPTION_SCHEMA({} if config is None else config) + + # Also called on merged same-port configs in final validate, where schemas # do not run def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: - if CONF_PASSWORD in config and CONF_ENCRYPTION in config: - raise cv.Invalid( - f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " - f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'" - ) - return config + if ( + CONF_PASSWORD not in config + or (encryption := config.get(CONF_ENCRYPTION)) is None + ): + return config + # The migration install may still have to answer the old firmware's + # password prompt on the plaintext leg; the password is not built in + if encryption.get(CONF_ALLOW_PLAINTEXT_UPLOAD): + return config + raise cv.Invalid( + f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " + f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}' " + f"(or set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' for the one install that " + f"migrates a device still asking for it)" + ) def _consume_ota_sockets(config: ConfigType) -> ConfigType: @@ -261,7 +293,7 @@ CONFIG_SCHEMA = cv.All( ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.sensitive(), - cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(CONF_ENCRYPTION): _encryption_schema, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), @@ -296,7 +328,10 @@ async def to_code(config: ConfigType) -> None: # An empty password opts in to the auth code path so set_auth_password() can be # called at runtime (e.g. to rotate the password from a lambda). When `password:` # is omitted entirely, the auth path is excluded to save flash on small devices. - if CONF_PASSWORD in config: + # A password is never built in next to encryption: validation only lets + # the two coexist for the migration install, where the password answers + # the running firmware and the build is authenticated by the key + if CONF_PASSWORD in config and CONF_ENCRYPTION not in config: cg.add_define("USE_OTA_PASSWORD") if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) @@ -308,20 +343,16 @@ async def to_code(config: ConfigType) -> None: # One key per device: an api encryption block supplies it (static or # runtime) and offers; the ota block only adds the requirement 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: + if key := static_encryption_key(config) or static_encryption_key(api_conf): + # Build time key: the ota keeps its own pointer so safe mode, which + # has no api server, still has it cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) elif CONF_ENCRYPTION in api_conf: + # Runtime key: found in the api server, or in preferences in safe mode 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_PROVISIONED") + if CONF_ENCRYPTION in config: 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. diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1005ed214b6..22580343e88 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,5 +1,5 @@ #include "ota_esphome.h" -#ifdef USE_OTA_ENCRYPTION_FROM_API +#ifdef USE_OTA_ENCRYPTION_PROVISIONED #include "esphome/components/api/api_server.h" #endif #ifdef USE_OTA @@ -32,16 +32,21 @@ 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_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // The api server holds the live key; safe mode never constructs it, and then + // noise_ctx_ holds the saved key setup() found, if any + if (api::global_api_server != nullptr) + return api::global_api_server->get_noise_ctx(); #endif + return this->noise_ctx_; } #endif 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_DATA = 90000; // milliseconds for data transfer +// Milliseconds for data transfer. Covers the lwIP retransmit run seen in +// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits +// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -55,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() { } void ESPHomeOTAComponent::setup() { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Safe mode never constructs the api server, so read the key it saved + noise::psk_t psk; + if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) { + this->saved_psk_ = RAMAllocator().make_unique(psk); + if (this->saved_psk_ != nullptr) { + this->noise_ctx_.set_psk(this->saved_psk_->data()); + } + } +#endif this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { this->server_failed_(LOG_STR("creation")); @@ -444,7 +459,10 @@ void ESPHomeOTAComponent::handle_data_() { tv.tv_usec = 0; this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); - this->client_->setblocking(true); + if (this->client_->setblocking(true) != 0) { + this->log_socket_error_(LOG_STR("blocking")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } // Acknowledge auth OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); @@ -452,6 +470,8 @@ void ESPHomeOTAComponent::handle_data_() { if (this->extended_proto_()) { // Read ota type, 1 byte if (!this->data_readall_(buf, 1)) { + if (this->client_left_before_start_()) + return; this->log_read_error_(LOG_STR("OTA type")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -461,6 +481,9 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->data_readall_(buf, 4)) { + // The first request byte is the type on the extended protocol; a close after it was a cut-off request + if (!this->extended_proto_() && this->client_left_before_start_()) + return; this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -527,6 +550,8 @@ void ESPHomeOTAComponent::handle_data_() { // there is no would-block retry here and failures are already logged. read = this->noise_read_data_(buf, requested); if (read <= 0) { + if (this->remote_closed_) + this->log_remote_closed_(LOG_STR("data")); error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -651,7 +676,11 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return false; } } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); + // A partial message is a cut-off request, not a clean close; the caller reports the clean one + this->remote_closed_ = at == 0; + if (at > 0) { + ESP_LOGW(TAG, "Remote closed after %u of %zu bytes", (unsigned) at, len); + } return false; } else { at += read; @@ -697,7 +726,22 @@ void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); } -void ESPHomeOTAComponent::log_read_error_(const LogString *what) { ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); } +bool ESPHomeOTAComponent::client_left_before_start_() { + // Key probes and scanners hang up right after the handshake; nothing started, so no error status or callback + if (!this->remote_closed_) + return false; + ESP_LOGD(TAG, "Client left after the handshake"); + this->cleanup_connection_(); + return true; +} + +void ESPHomeOTAComponent::log_read_error_(const LogString *what) { + if (this->remote_closed_) { + this->log_remote_closed_(what); + return; + } + ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); +} void ESPHomeOTAComponent::log_start_(const LogString *phase) { char peername[socket::SOCKADDR_STR_LEN]; @@ -778,6 +822,7 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->handshake_buf_pos_ = 0; this->ota_state_ = OTAState::IDLE; this->ota_features_ = 0; + this->remote_closed_ = false; this->backend_ = nullptr; #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); @@ -839,7 +884,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c6f710b3fcb..6f04b78da5b 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD -#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) +#ifdef USE_OTA_ENCRYPTION /// 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 @@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writing{false}; // a produced handshake frame is still being flushed 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 + // The api server's live context when it exists, otherwise our own (a build + // time key, or the saved key loaded in safe mode) const noise::NoiseContext &noise_context_() const; bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); @@ -133,6 +134,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { void server_failed_(const LogString *msg); void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); + bool client_left_before_start_(); void log_start_(const LogString *phase); void log_remote_closed_(const LogString *during); void cleanup_connection_(); @@ -145,13 +147,15 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION -#ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Backs noise_ctx_ in safe mode, where no api server holds the saved key + RAMUniquePtr saved_psk_; #endif - std::unique_ptr noise_; + RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; @@ -183,6 +187,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; + bool remote_closed_{false}; // the peer hung up cleanly during a blocking read #ifdef USE_OTA_PASSWORD uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d0..65476572a1e 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include -#include #ifdef USE_ESP8266 #include @@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { 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) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); 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_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 5541a6ee97d..c04cf1ed59b 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.udp import CONF_ON_RECEIVE import esphome.config_validation as cv from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -37,9 +39,6 @@ ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") SendAction = espnow_ns.class_("SendAction", automation.Action) -SetChannelAction = espnow_ns.class_("SetChannelAction", automation.Action) -AddPeerAction = espnow_ns.class_("AddPeerAction", automation.Action) -DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, @@ -132,6 +131,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: if address := config.get(CONF_ADDRESS): address = address.parts @@ -212,12 +229,16 @@ def _validate_raw_data(value: Any) -> str | list: ) +def _mac_bytes(address: core.MACAddress) -> list[HexInt]: + return [HexInt(p) for p in address.parts] + + async def register_peer( var: MockObj, config: ConfigType, args: TemplateArgsType ) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): - peer = [HexInt(p) for p in peer.parts] + peer = _mac_bytes(peer) template_ = await cg.templatable(peer, args, peer_address_t, peer_address_t) cg.add(var.set_address(template_)) @@ -303,40 +324,28 @@ async def send_action( return var -@automation.register_action( - "espnow.peer.add", - AddPeerAction, - cv.maybe_simple_value( - PEER_SCHEMA, - key=CONF_ADDRESS, - ), - synchronous=True, -) -@automation.register_action( - "espnow.peer.delete", - DeletePeerAction, - cv.maybe_simple_value( - PEER_SCHEMA, - key=CONF_ADDRESS, - ), - synchronous=True, -) -async def peer_action( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await register_peer(var, config, args) - - return var +def _peer_address(config: ConfigType, value: core.MACAddress) -> str: + return str(cg.safe_exp(_mac_bytes(value))) -@automation.register_action( +for _name, _method in ( + ("espnow.peer.add", "add_peer_from_action"), + ("espnow.peer.delete", "del_peer_from_action"), +): + automation.register_apply_action( + _name, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), + automation.ApplyField( + CONF_ADDRESS, _method, peer_address_t, const_fn=_peer_address + ), + ) + + +automation.register_apply_action( "espnow.set_channel", - SetChannelAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(ESPNowComponent), @@ -344,16 +353,5 @@ async def peer_action( }, key=CONF_CHANNEL, ), - synchronous=True, + automation.ApplyField(CONF_CHANNEL, "set_channel_from_action", cg.uint8), ) -async def channel_action( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - return var diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index e4d01bb1a8b..3c9c6e8be95 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -86,39 +86,6 @@ template class SendAction final : public Action, public P } flags_{0}; }; -template class AddPeerAction final : public Action, public Parented { - TEMPLATABLE_VALUE(peer_address_t, address); - - protected: - void play(const Ts &...x) override { - peer_address_t address = this->address_.value(x...); - this->parent_->add_peer(address.data()); - } -}; - -template class DeletePeerAction final : public Action, public Parented { - TEMPLATABLE_VALUE(peer_address_t, address); - - protected: - void play(const Ts &...x) override { - peer_address_t address = this->address_.value(x...); - this->parent_->del_peer(address.data()); - } -}; - -template class SetChannelAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, channel) - - protected: - void play(const Ts &...x) override { - if (this->parent_->is_wifi_enabled()) { - return; - } - this->parent_->set_wifi_channel(this->channel_.value(x...)); - this->parent_->apply_wifi_channel(); - } -}; - class OnReceiveTrigger final : public Trigger, public ESPNowReceivedPacketHandler { public: diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index af693b47cfc..375bd4faefe 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -116,9 +116,18 @@ class ESPNowComponent final : public Component { esp_err_t add_peer(const uint8_t *peer); // Remove a peer with the esp_now api and remove from the internal list if exists esp_err_t del_peer(const uint8_t *peer); + // Action entry points; distinct names because add_peer(peer_address_t) only fills the boot-time list + esp_err_t add_peer_from_action(const peer_address_t &address) { return this->add_peer(address.data()); } + esp_err_t del_peer_from_action(const peer_address_t &address) { return this->del_peer(address.data()); } void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } void apply_wifi_channel(); + void set_channel_from_action(uint8_t channel) { + if (this->is_wifi_enabled()) + return; + this->set_wifi_channel(channel); + this->apply_wifi_channel(); + } uint8_t get_wifi_channel(); void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f142..ce7206bf57a 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -53,12 +53,10 @@ from esphome.const import ( ) from esphome.core import ( CORE, - ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -140,6 +138,7 @@ ETHERNET_TYPES = { "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, "CH390": EthernetType.ETHERNET_TYPE_CH390, + "KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL, } # PHY types that need compile-time defines for conditional compilation @@ -162,6 +161,7 @@ _PHY_TYPE_TO_DEFINE = { "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", "CH390": "USE_ETHERNET_CH390", + "KSZ8851SNL": "USE_ETHERNET_KSZ8851SNL", } @@ -186,13 +186,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), + "KSZ8851SNL": IDFRegistryComponent("espressif/ksz8851snl", "1.2.0"), } # These types are always external IDF components (never built-in to ESP-IDF) _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) -SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390", "KSZ8851SNL"} # RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole # RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the # comment above is about ESP-IDF driver coverage, not the RP2 platform. @@ -420,7 +421,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, @@ -534,6 +537,12 @@ SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) # https://www.wch-ic.com/downloads/CH390DS1_PDF.html SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) +# The KSZ8851SNL rates fSCLK at 40 MHz maximum, so the shared 80 MHz ceiling is +# out of spec while the 26.67 MHz default is not. KSZ8851SNL/SNLI datasheet +# DS00002381C, table 7-1: +# https://ww1.microchip.com/downloads/aemDocuments/documents/UNG/ProductDocuments/DataSheets/KSZ8851SNL-Single-Port-Ethernet-Controller-with-SPI-DS00002381C.pdf +SPI_SCHEMA_KSZ8851SNL = _spi_schema(max_clock=int(40e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -549,6 +558,7 @@ CONFIG_SCHEMA = cv.All( "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "CH390": SPI_SCHEMA_CH390, + "KSZ8851SNL": SPI_SCHEMA_KSZ8851SNL, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), @@ -922,24 +932,15 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -async def _new_pvariable_to_code( - config: ConfigType, - id_: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(id_, template_arg) - - -for _name, _cls in ( - ("ethernet.connected", EthernetConnectedCondition), - ("ethernet.enabled", EthernetEnabledCondition), -): - automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) -for _name, _cls in ( - ("ethernet.enable", EthernetEnableAction), - ("ethernet.disable", EthernetDisableAction), -): - automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( - _new_pvariable_to_code - ) +automation.register_bare_condition( + "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) +) +automation.register_bare_condition( + "ethernet.enabled", EthernetEnabledCondition, cv.Schema({}) +) +automation.register_bare_action( + "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True +) +automation.register_bare_action( + "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2b67b9093b7..75dcfb45f66 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -92,6 +92,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, ETHERNET_TYPE_CH390, + ETHERNET_TYPE_KSZ8851SNL, }; struct ManualIP { diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1d9903271ec..d15ef5a34c6 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,9 @@ #include #include #include "esp_event.h" +#ifdef USE_PSRAM +#include +#endif // IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry; // they are no longer included via esp_eth.h and need explicit includes. @@ -38,6 +41,10 @@ #include "esp_eth_mac_dm9051.h" #include "esp_eth_phy_dm9051.h" #endif +#ifdef USE_ETHERNET_KSZ8851SNL +#include "esp_eth_mac_ksz8851snl.h" +#include "esp_eth_phy_ksz8851snl.h" +#endif #endif // ESP_IDF_VERSION >= 6.0.0 // LAN867x header exists on all IDF versions (external component since IDF 5.3) @@ -71,6 +78,32 @@ static const char *const TAG = "ethernet"; // PHY register size for hex logging static constexpr size_t PHY_REG_SIZE = 2; +// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are +// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces. +#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP) +#define USE_ETHERNET_RX_PSRAM +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config() +static bool rx_psram_installed = false; + +// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP +// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is. +static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) { + auto *copy = static_cast(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (copy != nullptr) { + memcpy(copy, buffer, length); + free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc() + buffer = copy; + } else { + static bool warned = false; // once, this runs per frame in the driver's task + if (!warned) { + warned = true; + ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)"); + } + } + return esp_netif_receive(static_cast(priv), buffer, length, nullptr); +} +#endif + void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); this->mark_failed(); @@ -235,6 +268,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_CH390) eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_KSZ8851SNL) + eth_ksz8851snl_config_t ksz8851snl_config = ETH_KSZ8851SNL_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -261,6 +296,11 @@ void EthernetComponent::ethernet_lazy_init_() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT ch390_config.poll_period_ms = this->polling_interval_; #endif +#elif defined(USE_ETHERNET_KSZ8851SNL) + ksz8851snl_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ksz8851snl_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -391,6 +431,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_ch390(&phy_config); break; } +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: { + mac = esp_eth_mac_new_ksz8851snl(&ksz8851snl_config, &mac_config); + this->phy_ = esp_eth_phy_new_ksz8851snl(&phy_config); + break; + } #endif #endif default: { @@ -453,6 +499,16 @@ void EthernetComponent::ethernet_lazy_init_() { /* attach Ethernet driver to TCP/IP stack */ err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); ESPHL_ERROR_CHECK(err, "ETH netif attach error"); +#ifdef USE_ETHERNET_RX_PSRAM + // The glue frees every receive buffer with free(), so the replacement buffer must come from the heap + if (esp_psram_is_initialized()) { + err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_); + rx_psram_installed = err == ESP_OK; + if (!rx_psram_installed) { + ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err)); + } + } +#endif // Register user defined event handers err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); @@ -554,6 +610,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_CH390: eth_type = "CH390"; break; +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: + eth_type = "KSZ8851SNL"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: @@ -630,6 +690,13 @@ void EthernetComponent::dump_config() { this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); #endif ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +#ifdef USE_ETHERNET_RX_PSRAM + // Only known once the driver is up; with enable_on_boot: false that is after this dump + if (this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " RX frames: %s", + rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM")); + } +#endif } network::IPAddresses EthernetComponent::get_ip_addresses() { diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738f..9c6b59582a3 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger // transfers (the frame payloads) use the blocking, DMA-backed transmit. @@ -25,23 +29,20 @@ constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; void *w5500_custom_spi_init(const void *spi_config) { const auto *config = static_cast(spi_config); - auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; - if (ctx == nullptr) { - return nullptr; - } + auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. spi_device_interface_config_t devcfg = *config->spi_devcfg; devcfg.command_bits = 16; devcfg.address_bits = 8; if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { - delete ctx; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { auto *ctx = static_cast(spi_ctx); spi_bus_remove_device(ctx->handle); vSemaphoreDelete(ctx->lock); - delete ctx; + *ctx = {}; return ESP_OK; } diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 11002d51bad..c9fcda225f4 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -13,6 +13,9 @@ class IPAddressEthernetInfo final : public Component, public text_sensor::TextSensor, public ethernet::EthernetIPStateListener { public: + // User provided, not "= default": `new(p) IPAddressEthernetInfo()` would zero-fill .bss that is already zero. + IPAddressEthernetInfo() {} + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 881107b713a..d02863b9ee2 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,14 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@nohat"] @@ -40,8 +40,6 @@ event_ns = cg.esphome_ns.namespace("event") Event = event_ns.class_("Event", cg.EntityBase) EventPtr = Event.operator("ptr") -TriggerEventAction = event_ns.class_("TriggerEventAction", automation.Action) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") _EVENT_SCHEMA = ( @@ -135,20 +133,18 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) -@automation.register_action( - "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True +def _event_type_literal(config: ConfigType, value: str) -> str: + """A constant event type is a plain literal; trigger() only compares it, so no copy is needed.""" + return str(cg.safe_exp(value)) + + +automation.register_apply_action( + "event.trigger", + TRIGGER_EVENT_SCHEMA, + automation.ApplyField( + CONF_EVENT_TYPE, "trigger", cg.std_string, _event_type_literal + ), ) -async def event_fire_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]) - templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) - cg.add(var.set_event_type(templ)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h deleted file mode 100644 index 73a6336f780..00000000000 --- a/esphome/components/event/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "esphome/components/event/event.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::event { - -template class TriggerEventAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, event_type) - - void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } -}; - -class EventTrigger final : public Trigger { - public: - EventTrigger(Event *event) { - event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); - } -}; - -} // namespace esphome::event diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 673ccc98028..00bb9497f96 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -7,17 +7,17 @@ namespace esphome::event { static const char *const TAG = "event"; -void Event::trigger(const std::string &event_type) { +void Event::trigger(const char *event_type) { // Linear search with strcmp - faster than std::set for small datasets (1-5 items typical) const char *found = nullptr; for (const char *type : this->types_) { - if (strcmp(type, event_type.c_str()) == 0) { + if (strcmp(type, event_type) == 0) { found = type; break; } } if (found == nullptr) { - ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type.c_str()); + ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type); return; } this->last_event_type_ = found; @@ -36,12 +36,4 @@ void Event::set_event_types(const FixedVector &event_types) { this->last_event_type_ = nullptr; // Reset when types change } -void Event::set_event_types(const std::vector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } - this->last_event_type_ = nullptr; // Reset when types change -} - } // namespace esphome::event diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index e6fc7111c8b..f57327e33f0 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -3,7 +3,6 @@ #include #include #include -#include #include "esphome/core/component.h" #include "esphome/core/entity_base.h" @@ -21,22 +20,21 @@ namespace esphome::event { class Event : public EntityBase { public: - void trigger(const std::string &event_type); + /// Trigger an event; the type is matched against the configured types by string compare. + void trigger(const char *event_type); + void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event (from initializer list). + /// Set the event types supported by this event; called by generated code with string literals. void set_event_types(std::initializer_list event_types) { this->types_ = event_types; this->last_event_type_ = nullptr; // Reset when types change } - /// Set the event types supported by this event (from FixedVector). + /// Copy the event types of another event, for components that wrap one. void set_event_types(const FixedVector &event_types); - /// Set the event types supported by this event (from vector). - void set_event_types(const std::vector &event_types); // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; - void set_event_types(const std::vector &event_types) = delete; /// Return the event types supported by this event. const FixedVector &get_event_types() const { return this->types_; } diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 0793495e1ac..1157ef1fb01 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -44,112 +44,19 @@ EZO_PMP_NO_ARGS_ACTION_SCHEMA = maybe_simple_id( } ) -# Actions that do not require more arguments - -EzoPMPFindAction = ezo_pmp_ns.class_("EzoPMPFindAction", automation.Action) -EzoPMPClearTotalVolumeDispensedAction = ezo_pmp_ns.class_( - "EzoPMPClearTotalVolumeDispensedAction", automation.Action -) -EzoPMPClearCalibrationAction = ezo_pmp_ns.class_( - "EzoPMPClearCalibrationAction", automation.Action -) -EzoPMPPauseDosingAction = ezo_pmp_ns.class_( - "EzoPMPPauseDosingAction", automation.Action -) -EzoPMPStopDosingAction = ezo_pmp_ns.class_("EzoPMPStopDosingAction", automation.Action) -EzoPMPDoseContinuouslyAction = ezo_pmp_ns.class_( - "EzoPMPDoseContinuouslyAction", automation.Action -) - -# Actions that require more arguments -EzoPMPDoseVolumeAction = ezo_pmp_ns.class_("EzoPMPDoseVolumeAction", automation.Action) -EzoPMPDoseVolumeOverTimeAction = ezo_pmp_ns.class_( - "EzoPMPDoseVolumeOverTimeAction", automation.Action -) -EzoPMPDoseWithConstantFlowRateAction = ezo_pmp_ns.class_( - "EzoPMPDoseWithConstantFlowRateAction", automation.Action -) -EzoPMPSetCalibrationVolumeAction = ezo_pmp_ns.class_( - "EzoPMPSetCalibrationVolumeAction", automation.Action -) -EzoPMPChangeI2CAddressAction = ezo_pmp_ns.class_( - "EzoPMPChangeI2CAddressAction", automation.Action -) -EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( - "EzoPMPArbitraryCommandAction", automation.Action -) - - -@automation.register_action( - "ezo_pmp.find", - EzoPMPFindAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def ezo_pmp_find_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "ezo_pmp.dose_continuously", - EzoPMPDoseContinuouslyAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "ezo_pmp.clear_total_volume_dosed", - EzoPMPClearTotalVolumeDispensedAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def ezo_pmp_clear_total_volume_dosed_to_code( - config, action_id, template_arg, args +for _name, _method in ( + ("ezo_pmp.find", "find()"), + ("ezo_pmp.dose_continuously", "dose_continuously()"), + ("ezo_pmp.clear_total_volume_dosed", "clear_total_volume_dosed()"), + ("ezo_pmp.clear_calibration", "clear_calibration()"), + ("ezo_pmp.pause_dosing", "pause_dosing()"), + ("ezo_pmp.stop_dosing", "stop_dosing()"), ): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) + automation.register_apply_action( + _name, EZO_PMP_NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_method) + ) -@automation.register_action( - "ezo_pmp.clear_calibration", - EzoPMPClearCalibrationAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "ezo_pmp.pause_dosing", - EzoPMPPauseDosingAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "ezo_pmp.stop_dosing", - EzoPMPStopDosingAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def ezo_pmp_stop_dosing_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -# Actions that require Multiple Args - EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(EzoPMP), @@ -159,21 +66,11 @@ EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_volume", - EzoPMPDoseVolumeAction, EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "dose_volume", cg.double), ) -async def ezo_pmp_dose_volume_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - return var EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( @@ -188,24 +85,14 @@ EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_volume_over_time", - EzoPMPDoseVolumeOverTimeAction, EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "dose_volume_over_time({}, {})", + ((CONF_VOLUME, cg.double), (CONF_DURATION, cg.int_)), + ), ) -async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) - cg.add(var.set_duration(template_)) - - return var EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( @@ -220,26 +107,14 @@ EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_with_constant_flow_rate", - EzoPMPDoseWithConstantFlowRateAction, EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "dose_with_constant_flow_rate({}, {})", + ((CONF_VOLUME_PER_MINUTE, cg.double), (CONF_DURATION, cg.int_)), + ), ) -async def ezo_pmp_dose_with_constant_flow_rate_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME_PER_MINUTE], args, cg.double) - cg.add(var.set_volume(template_)) - - template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) - cg.add(var.set_duration(template_)) - - return var EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( @@ -251,21 +126,11 @@ EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.set_calibration_volume", - EzoPMPSetCalibrationVolumeAction, EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_calibration_volume", cg.double), ) -async def ezo_pmp_set_calibration_volume_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - return var EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( @@ -275,21 +140,11 @@ EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.change_i2c_address", - EzoPMPChangeI2CAddressAction, EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "change_i2c_address", cg.int_), ) -async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) - cg.add(var.set_address(template_)) - - return var EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( @@ -299,18 +154,8 @@ EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.arbitrary_command", - EzoPMPArbitraryCommandAction, EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_COMMAND, "exec_arbitrary_command", cg.std_string), ) -async def ezo_pmp_arbitrary_command_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) - cg.add(var.set_command(template_)) - - return var diff --git a/esphome/components/ezo_pmp/binary_sensor.py b/esphome/components/ezo_pmp/binary_sensor.py index a81b6c09f05..fcdb94b62b2 100644 --- a/esphome/components/ezo_pmp/binary_sensor.py +++ b/esphome/components/ezo_pmp/binary_sensor.py @@ -31,12 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_PUMP_STATE in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_PUMP_STATE]) - cg.add(parent.set_is_dosing(sens)) - - if CONF_IS_PAUSED in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_IS_PAUSED]) - cg.add(parent.set_is_paused(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUMP_STATE, hub.set_is_dosing) + await binary_sensors(CONF_IS_PAUSED, hub.set_is_paused) diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 55283f2d097..ab05302a55b 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/i2c/i2c.h" #ifdef USE_BINARY_SENSOR @@ -113,137 +112,4 @@ class EzoPMP final : public PollingComponent, public i2c::I2CDevice { #endif }; -// Action Templates -template class EzoPMPFindAction final : public Action { - public: - EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->find(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseContinuouslyAction final : public Action { - public: - EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->dose_continuously(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseVolumeAction final : public Action { - public: - EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->dose_volume(this->volume_.value(x...)); } - TEMPLATABLE_VALUE(double, volume) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseVolumeOverTimeAction final : public Action { - public: - EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { - this->ezopmp_->dose_volume_over_time(this->volume_.value(x...), this->duration_.value(x...)); - } - TEMPLATABLE_VALUE(double, volume) - TEMPLATABLE_VALUE(int, duration) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseWithConstantFlowRateAction final : public Action { - public: - EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { - this->ezopmp_->dose_with_constant_flow_rate(this->volume_.value(x...), this->duration_.value(x...)); - } - TEMPLATABLE_VALUE(double, volume) - TEMPLATABLE_VALUE(int, duration) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPSetCalibrationVolumeAction final : public Action { - public: - EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->set_calibration_volume(this->volume_.value(x...)); } - TEMPLATABLE_VALUE(double, volume) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPClearTotalVolumeDispensedAction final : public Action { - public: - EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->clear_total_volume_dosed(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPClearCalibrationAction final : public Action { - public: - EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->clear_calibration(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPPauseDosingAction final : public Action { - public: - EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->pause_dosing(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPStopDosingAction final : public Action { - public: - EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->stop_dosing(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPChangeI2CAddressAction final : public Action { - public: - EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->change_i2c_address(this->address_.value(x...)); } - TEMPLATABLE_VALUE(int, address) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPArbitraryCommandAction final : public Action { - public: - EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->exec_arbitrary_command(this->command_.value(x...)); } - TEMPLATABLE_VALUE(std::string, command) - - protected: - EzoPMP *ezopmp_; -}; - } // namespace esphome::ezo_pmp diff --git a/esphome/components/ezo_pmp/sensor.py b/esphome/components/ezo_pmp/sensor.py index ed4efeeabcb..1ff214de78e 100644 --- a/esphome/components/ezo_pmp/sensor.py +++ b/esphome/components/ezo_pmp/sensor.py @@ -76,28 +76,12 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_CURRENT_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_CURRENT_VOLUME_DOSED]) - cg.add(parent.set_current_volume_dosed(sens)) - - if CONF_LAST_VOLUME_REQUESTED in config: - sens = await sensor.new_sensor(config[CONF_LAST_VOLUME_REQUESTED]) - cg.add(parent.set_last_volume_requested(sens)) - - if CONF_TOTAL_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_VOLUME_DOSED]) - cg.add(parent.set_total_volume_dosed(sens)) - - if CONF_ABSOLUTE_TOTAL_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_ABSOLUTE_TOTAL_VOLUME_DOSED]) - cg.add(parent.set_absolute_total_volume_dosed(sens)) - - if CONF_PUMP_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_PUMP_VOLTAGE]) - cg.add(parent.set_pump_voltage(sens)) - - if CONF_MAX_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_MAX_FLOW_RATE]) - cg.add(parent.set_max_flow_rate(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CURRENT_VOLUME_DOSED, hub.set_current_volume_dosed) + await sensors(CONF_LAST_VOLUME_REQUESTED, hub.set_last_volume_requested) + await sensors(CONF_TOTAL_VOLUME_DOSED, hub.set_total_volume_dosed) + await sensors(CONF_ABSOLUTE_TOTAL_VOLUME_DOSED, hub.set_absolute_total_volume_dosed) + await sensors(CONF_PUMP_VOLTAGE, hub.set_pump_voltage) + await sensors(CONF_MAX_FLOW_RATE, hub.set_max_flow_rate) diff --git a/esphome/components/ezo_pmp/text_sensor.py b/esphome/components/ezo_pmp/text_sensor.py index afae1e5188f..47651d0d1da 100644 --- a/esphome/components/ezo_pmp/text_sensor.py +++ b/esphome/components/ezo_pmp/text_sensor.py @@ -24,12 +24,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_DOSING_MODE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DOSING_MODE]) - cg.add(parent.set_dosing_mode(sens)) - - if CONF_CALIBRATION_STATUS in config: - sens = await text_sensor.new_text_sensor(config[CONF_CALIBRATION_STATUS]) - cg.add(parent.set_calibration_status(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DOSING_MODE, hub.set_dosing_mode) + await text_sensors(CONF_CALIBRATION_STATUS, hub.set_calibration_status) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 3949f16d2e1..526499fe077 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -31,19 +31,17 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression IS_PLATFORM_COMPONENT = True fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) -FanCall = fan_ns.class_("FanCall") FanDirection = fan_ns.enum("FanDirection", is_class=True) FAN_DIRECTION_ENUM = { @@ -63,9 +61,6 @@ RESTORE_MODES = { } # Actions -TurnOnAction = fan_ns.class_("TurnOnAction", automation.Action) -TurnOffAction = fan_ns.class_("TurnOffAction", automation.Action) -ToggleAction = fan_ns.class_("ToggleAction", automation.Action) CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) FanStateTrigger = fan_ns.class_( @@ -86,9 +81,6 @@ FanPresetSetTrigger = fan_ns.class_( "FanPresetSetTrigger", automation.Trigger.template(cg.StringRef) ) -FanIsOnCondition = fan_ns.class_("FanIsOnCondition", automation.Condition.template()) -FanIsOffCondition = fan_ns.class_("FanIsOffCondition", automation.Condition.template()) - _FAN_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -316,25 +308,11 @@ FAN_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "fan.toggle", ToggleAction, FAN_ACTION_SCHEMA, synchronous=True -) -async def fan_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +automation.register_apply_action("fan.toggle", FAN_ACTION_SCHEMA, call="toggle") +automation.register_apply_action("fan.turn_off", FAN_ACTION_SCHEMA, call="turn_off") - -@automation.register_action( - "fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA, synchronous=True -) -async def fan_turn_off_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( +automation.register_apply_action( "fan.turn_on", - TurnOnAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Fan), @@ -345,50 +323,11 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args): ), } ), - synchronous=True, + automation.ApplyField(CONF_OSCILLATING, "set_oscillating", cg.bool_), + automation.ApplyField(CONF_SPEED, "set_speed", cg.int_), + automation.ApplyField(CONF_DIRECTION, "set_direction", FanDirection), + call="turn_on", ) -async def fan_turn_on_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_OSCILLATING, "set_oscillating", cg.bool_), - (CONF_SPEED, "set_speed", cg.int_), - (CONF_DIRECTION, "set_direction", FanDirection), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches TurnOnAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (FanCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @automation.register_action( @@ -410,27 +349,16 @@ async def fan_cycle_speed_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( - "fan.is_on", - FanIsOnCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(Fan), - } - ), +FAN_CONDITION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(Fan), + } ) -@automation.register_condition( - "fan.is_off", - FanIsOffCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(Fan), - } - ), + +automation.register_apply_condition("fan.is_on", FAN_CONDITION_SCHEMA, "state") +automation.register_apply_condition( + "fan.is_off", FAN_CONDITION_SCHEMA, "state == false" ) -async def fan_is_on_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index cbd994e749a..4c587904b73 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,51 +6,6 @@ namespace esphome::fan { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `speed: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction final : public Action { - public: - using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); - TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->state_->turn_on(); - this->apply_(call, x...); - call.perform(); - } - - Fan *state_; - ApplyFn apply_; -}; - -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->turn_off().perform(); } - - Fan *state_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->toggle().perform(); } - - Fan *state_; -}; - template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} @@ -95,23 +50,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -template class FanIsOnCondition final : public Condition { - public: - explicit FanIsOnCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->state; } - - protected: - Fan *state_; -}; -template class FanIsOffCondition final : public Condition { - public: - explicit FanIsOffCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->state; } - - protected: - Fan *state_; -}; - class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 106e6e74cd8..7e21971639e 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -183,7 +183,7 @@ class Fan : public EntityBase { LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; - FanRestoreMode restore_mode_; + FanRestoreMode restore_mode_{FanRestoreMode::NO_RESTORE}; private: /// Lazy-allocate preset modes vector (never freed — entity lives forever). diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a4..ab769954124 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 8d935a3c9ed..0266a9caad5 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -37,16 +37,6 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( "FingerprintGrowComponent", cg.PollingComponent, uart.UARTDevice ) -EnrollmentAction = fingerprint_grow_ns.class_("EnrollmentAction", automation.Action) -CancelEnrollmentAction = fingerprint_grow_ns.class_( - "CancelEnrollmentAction", automation.Action -) -DeleteAction = fingerprint_grow_ns.class_("DeleteAction", automation.Action) -DeleteAllAction = fingerprint_grow_ns.class_("DeleteAllAction", automation.Action) -LEDControlAction = fingerprint_grow_ns.class_("LEDControlAction", automation.Action) -AuraLEDControlAction = fingerprint_grow_ns.class_( - "AuraLEDControlAction", automation.Action -) AuraLEDState = fingerprint_grow_ns.enum("GrowAuraLEDState", True) AURA_LED_STATES = { @@ -181,111 +171,68 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( - "fingerprint_grow.enroll", - EnrollmentAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), - cv.Optional(CONF_NUM_SCANS): cv.templatable(cv.uint8_t), - }, - key=CONF_FINGER_ID, - ), - synchronous=True, -) -async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_ = await cg.templatable(config[CONF_FINGER_ID], args, cg.uint16) - cg.add(var.set_finger_id(template_)) - if CONF_NUM_SCANS in config: - template_ = await cg.templatable(config[CONF_NUM_SCANS], args, cg.uint8) - cg.add(var.set_num_scans(template_)) - return var - - -@automation.register_action( - "fingerprint_grow.cancel_enroll", - CancelEnrollmentAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - } - ), - synchronous=True, -) -async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "fingerprint_grow.delete", - DeleteAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), - }, - key=CONF_FINGER_ID, - ), - synchronous=True, -) -async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_ = await cg.templatable(config[CONF_FINGER_ID], args, cg.uint16) - cg.add(var.set_finger_id(template_)) - return var - - -@automation.register_action( - "fingerprint_grow.delete_all", - DeleteAllAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - } - ), - synchronous=True, -) -async def fingerprint_grow_delete_all_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( +FINGERPRINT_GROW_ID_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_STATE): cv.templatable(cv.boolean), - }, - key=CONF_STATE, + } ) +automation.register_apply_action( + "fingerprint_grow.enroll", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), + cv.Optional(CONF_NUM_SCANS, default=2): cv.templatable( + cv.int_range(min=1, max=255) + ), + }, + key=CONF_FINGER_ID, + ), + automation.ApplyCall( + "enroll_fingerprint({}, {})", + ((CONF_FINGER_ID, cg.uint16), (CONF_NUM_SCANS, cg.uint8)), + ), +) -@automation.register_action( +automation.register_apply_action( + "fingerprint_grow.cancel_enroll", + FINGERPRINT_GROW_ID_SCHEMA, + automation.ApplyCall("finish_enrollment(1)"), +) + +automation.register_apply_action( + "fingerprint_grow.delete", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), + }, + key=CONF_FINGER_ID, + ), + automation.ApplyField(CONF_FINGER_ID, "delete_fingerprint", cg.uint16), +) + +automation.register_apply_action( + "fingerprint_grow.delete_all", + FINGERPRINT_GROW_ID_SCHEMA, + automation.ApplyCall("delete_all_fingerprints()"), +) + +automation.register_apply_action( "fingerprint_grow.led_control", - LEDControlAction, - FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA, - synchronous=True, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_STATE): cv.templatable(cv.boolean), + }, + key=CONF_STATE, + ), + automation.ApplyField(CONF_STATE, "led_control", cg.bool_), ) -async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "fingerprint_grow.aura_led_control", - AuraLEDControlAction, cv.Schema( { cv.GenerateID(): cv.use_id(FingerprintGrowComponent), @@ -295,15 +242,13 @@ async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, cv.Required(CONF_COUNT): cv.templatable(cv.uint8_t), } ), - synchronous=True, + automation.ApplyCall( + "aura_led_control({}, {}, {}, {})", + ( + (CONF_STATE, cg.uint8), + (CONF_SPEED, cg.uint8), + (CONF_COLOR, cg.uint8), + (CONF_COUNT, cg.uint8), + ), + ), ) -async def fingerprint_grow_aura_led_control_to_code( - config, action_id, template_arg, args -): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - for key in [CONF_STATE, CONF_SPEED, CONF_COLOR, CONF_COUNT]: - template_ = await cg.templatable(config[key], args, cg.uint8) - cg.add(getattr(var, f"set_{key}")(template_)) - return var diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 67662192ee4..5256d4489ea 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/uart/uart.h" @@ -209,71 +208,4 @@ class FingerprintGrowComponent final : public PollingComponent, public uart::UAR CallbackManager enrollment_failed_callback_; }; -template -class EnrollmentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, finger_id) - TEMPLATABLE_VALUE(uint8_t, num_scans) - - void play(const Ts &...x) override { - auto finger_id = this->finger_id_.value(x...); - auto num_scans = this->num_scans_.value(x...); - if (num_scans) { - this->parent_->enroll_fingerprint(finger_id, num_scans); - } else { - this->parent_->enroll_fingerprint(finger_id, 2); - } - } -}; - -template -class CancelEnrollmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } -}; - -template class DeleteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, finger_id) - - void play(const Ts &...x) override { - auto finger_id = this->finger_id_.value(x...); - this->parent_->delete_fingerprint(finger_id); - } -}; - -template class DeleteAllAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } -}; - -template -class LEDControlAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto state = this->state_.value(x...); - this->parent_->led_control(state); - } -}; - -template -class AuraLEDControlAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, speed) - TEMPLATABLE_VALUE(uint8_t, color) - TEMPLATABLE_VALUE(uint8_t, count) - - void play(const Ts &...x) override { - auto state = this->state_.value(x...); - auto speed = this->speed_.value(x...); - auto color = this->color_.value(x...); - auto count = this->count_.value(x...); - - this->parent_->aura_led_control(state, speed, color, count); - } -}; - } // namespace esphome::fingerprint_grow diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index f801239153c..9995a3abbd9 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -2,87 +2,78 @@ namespace esphome::fujitsu_general { -// bytes' bits are reversed for fujitsu, so nibbles are ordered 1, 0, 3, 2, 5, 4, etc... - -#define SET_NIBBLE(message, nibble, value) \ - ((message)[(nibble) / 2] |= ((value) &0b00001111) << (((nibble) % 2) ? 0 : 4)) -#define GET_NIBBLE(message, nibble) (((message)[(nibble) / 2] >> (((nibble) % 2) ? 0 : 4)) & 0b00001111) - static const char *const TAG = "fujitsu_general.climate"; // Common header -const uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE0 = 0x14; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE1 = 0x63; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE2 = 0x00; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE3 = 0x10; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE4 = 0x10; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_BYTE = 5; +constexpr uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE0 = 0x14; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE1 = 0x63; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE2 = 0x00; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE3 = 0x10; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE4 = 0x10; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_BYTE = 5; // State message - temp & fan etc. -const uint8_t FUJITSU_GENERAL_STATE_MESSAGE_LENGTH = 16; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; +constexpr uint8_t FUJITSU_GENERAL_STATE_MESSAGE_LENGTH = 16; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; // Util messages - off & eco etc. -const uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; +constexpr uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; // State header -const uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; -const uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; +constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; +constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; // State footer -const uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; - -// Temperature -const uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; // Power on -const uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; -const uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; -const uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; +constexpr uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; // Mode -const uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; -const uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; -const uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; -const uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; -const uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; -const uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; -// const uint8_t FUJITSU_GENERAL_MODE_10C = 0x0B; +// Bit 3 is the clean flag, which is also 10 degree heat on the ARRAH2E and ARREW4E remotes. +constexpr uint8_t FUJITSU_GENERAL_MODE_MASK = 0b0111; +constexpr uint8_t FUJITSU_GENERAL_CLEAN_BIT = 0b1000; +constexpr uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; +constexpr uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; +constexpr uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; +constexpr uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; +constexpr uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; // Swing -const uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; -const uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; -const uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; -const uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; -const uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; +constexpr uint8_t FUJITSU_GENERAL_SWING_MASK = 0b0011; +constexpr uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; +constexpr uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; +constexpr uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; +constexpr uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; // Fan -const uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; -const uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; -const uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; -const uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; -const uint8_t FUJITSU_GENERAL_FAN_LOW = 0x03; -const uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; +constexpr uint8_t FUJITSU_GENERAL_FAN_MASK = 0b0111; +constexpr uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; +constexpr uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; +constexpr uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; +constexpr uint8_t FUJITSU_GENERAL_FAN_LOW = 0x03; +constexpr uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; // TODO Outdoor Unit Low Noise // const uint8_t FUJITSU_GENERAL_OUTDOOR_UNIT_LOW_NOISE_BYTE14 = 0xA0; // const uint8_t FUJITSU_GENERAL_STATE_BYTE14 = 0x20; -const uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; -const uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; +constexpr uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; +constexpr uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; -const uint16_t FUJITSU_GENERAL_BIT_MARK = 420; -const uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; -const uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; +constexpr uint16_t FUJITSU_GENERAL_BIT_MARK = 420; +constexpr uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; +constexpr uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; -const uint16_t FUJITSU_GENERAL_TRL_MARK = 420; -const uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; +constexpr uint16_t FUJITSU_GENERAL_TRL_MARK = 420; +constexpr uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; -const uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; +constexpr uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; void FujitsuGeneralClimate::transmit_state() { if (this->mode == climate::CLIMATE_MODE_OFF) { @@ -111,68 +102,67 @@ void FujitsuGeneralClimate::transmit_state() { uint8_t temperature_clamped = (uint8_t) roundf(clamp(this->target_temperature, FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX)); uint8_t temperature_offset = temperature_clamped - FUJITSU_GENERAL_TEMP_MIN; - SET_NIBBLE(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); + set_nibble(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); // Set power on if (!this->power_) { - SET_NIBBLE(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); + set_nibble(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); } // Set mode switch (this->mode) { case climate::CLIMATE_MODE_COOL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); break; case climate::CLIMATE_MODE_HEAT: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); break; case climate::CLIMATE_MODE_DRY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); break; case climate::CLIMATE_MODE_FAN_ONLY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); break; case climate::CLIMATE_MODE_HEAT_COOL: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); break; - // TODO: CLIMATE_MODE_10C is missing from esphome } // Set fan switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_HIGH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); break; case climate::CLIMATE_FAN_MEDIUM: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); break; case climate::CLIMATE_FAN_LOW: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); break; case climate::CLIMATE_FAN_QUIET: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); break; case climate::CLIMATE_FAN_AUTO: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); break; } // Set swing switch (this->swing_mode) { case climate::CLIMATE_SWING_VERTICAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); break; case climate::CLIMATE_SWING_HORIZONTAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); break; case climate::CLIMATE_SWING_BOTH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); break; case climate::CLIMATE_SWING_OFF: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); break; } @@ -243,6 +233,52 @@ uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } +// These decoders use if chains rather than switches: on ESP8266 the compiler turns a dense switch +// into a lookup table in .rodata, which lives in RAM there. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode) { + const uint8_t mode = mode_field & FUJITSU_GENERAL_MODE_MASK; + if (mode == FUJITSU_GENERAL_MODE_COOL) + return climate::CLIMATE_MODE_COOL; + if (mode == FUJITSU_GENERAL_MODE_HEAT) + return climate::CLIMATE_MODE_HEAT; + if (mode == FUJITSU_GENERAL_MODE_DRY) + return climate::CLIMATE_MODE_DRY; + if (mode == FUJITSU_GENERAL_MODE_FAN) + return climate::CLIMATE_MODE_FAN_ONLY; + if (mode == FUJITSU_GENERAL_MODE_AUTO) + return climate::CLIMATE_MODE_HEAT_COOL; + // A state frame means the unit is on, so never keep OFF. + ESP_LOGW(TAG, "Received unassigned mode %X, keeping the current mode", mode); + return current_mode == climate::CLIMATE_MODE_OFF ? climate::CLIMATE_MODE_HEAT_COOL : current_mode; +} + +optional decode_fan_mode(uint8_t fan_field, optional current_mode) { + const uint8_t fan = fan_field & FUJITSU_GENERAL_FAN_MASK; + if (fan == FUJITSU_GENERAL_FAN_HIGH) + return climate::CLIMATE_FAN_HIGH; + if (fan == FUJITSU_GENERAL_FAN_MEDIUM) + return climate::CLIMATE_FAN_MEDIUM; + if (fan == FUJITSU_GENERAL_FAN_LOW) + return climate::CLIMATE_FAN_LOW; + if (fan == FUJITSU_GENERAL_FAN_SILENT) + return climate::CLIMATE_FAN_QUIET; + if (fan == FUJITSU_GENERAL_FAN_AUTO) + return climate::CLIMATE_FAN_AUTO; + ESP_LOGW(TAG, "Received unassigned fan speed %X, keeping the current fan mode", fan); + return current_mode; +} + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field) { + const uint8_t swing = swing_field & FUJITSU_GENERAL_SWING_MASK; + if (swing == FUJITSU_GENERAL_SWING_VERTICAL) + return climate::CLIMATE_SWING_VERTICAL; + if (swing == FUJITSU_GENERAL_SWING_HORIZONTAL) + return climate::CLIMATE_SWING_HORIZONTAL; + if (swing == FUJITSU_GENERAL_SWING_BOTH) + return climate::CLIMATE_SWING_BOTH; + return climate::CLIMATE_SWING_OFF; +} + bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { ESP_LOGV(TAG, "Received IR message"); @@ -316,70 +352,25 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { } if (recv_message_type == FUJITSU_GENERAL_MESSAGE_TYPE_STATE) { - const uint8_t recv_tempertature = GET_NIBBLE(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); + const uint8_t recv_tempertature = get_nibble(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); const uint8_t offset_temperature = recv_tempertature + FUJITSU_GENERAL_TEMP_MIN; this->target_temperature = offset_temperature; ESP_LOGV(TAG, "Received temperature %d", offset_temperature); - const uint8_t recv_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); + const uint8_t recv_mode = get_nibble(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); ESP_LOGV(TAG, "Received mode %X", recv_mode); - switch (recv_mode) { - case FUJITSU_GENERAL_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - case FUJITSU_GENERAL_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - case FUJITSU_GENERAL_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - case FUJITSU_GENERAL_MODE_FAN: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - case FUJITSU_GENERAL_MODE_AUTO: - default: - // TODO: CLIMATE_MODE_10C is missing from esphome - this->mode = climate::CLIMATE_MODE_HEAT_COOL; - break; + if ((recv_mode & FUJITSU_GENERAL_CLEAN_BIT) != 0) { + ESP_LOGW(TAG, "Received a frame with the clean / 10 degree heat bit set, which is not supported"); } + this->mode = decode_mode(recv_mode, this->mode); - const uint8_t recv_fan_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); + const uint8_t recv_fan_mode = get_nibble(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); ESP_LOGV(TAG, "Received fan mode %X", recv_fan_mode); - switch (recv_fan_mode) { - case FUJITSU_GENERAL_FAN_SILENT: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - case FUJITSU_GENERAL_FAN_LOW: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - case FUJITSU_GENERAL_FAN_MEDIUM: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - case FUJITSU_GENERAL_FAN_HIGH: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - case FUJITSU_GENERAL_FAN_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } + this->fan_mode = decode_fan_mode(recv_fan_mode, this->fan_mode); - const uint8_t recv_swing_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); + const uint8_t recv_swing_mode = get_nibble(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); ESP_LOGV(TAG, "Received swing mode %X", recv_swing_mode); - switch (recv_swing_mode) { - case FUJITSU_GENERAL_SWING_VERTICAL: - this->swing_mode = climate::CLIMATE_SWING_VERTICAL; - break; - case FUJITSU_GENERAL_SWING_HORIZONTAL: - this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; - break; - case FUJITSU_GENERAL_SWING_BOTH: - this->swing_mode = climate::CLIMATE_SWING_BOTH; - break; - case FUJITSU_GENERAL_SWING_NONE: - default: - this->swing_mode = climate::CLIMATE_SWING_OFF; - } + this->swing_mode = decode_swing_mode(recv_swing_mode); this->power_ = true; } diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 8d2ec883da9..20bda883b2e 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -7,8 +7,8 @@ namespace esphome::fujitsu_general { -const uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH -const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius +constexpr uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH +constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius // clang-format off /** @@ -43,9 +43,36 @@ const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius * heat 30 swing vert 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00101000 00000000 00000000 00000000 00000100 00011101 * heat 30 noswing 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00100000 00000000 00000000 00000000 00000100 00010011 * ``` + * + * The column markers show which bits varied in these captures, not field widths. */ // clang-format on +// Bits are reversed within each byte, so an odd nibble index is the low half of its byte. +constexpr uint8_t get_nibble(const uint8_t *message, uint8_t nibble) { + return (message[nibble / 2] >> ((nibble % 2) ? 0 : 4)) & 0b00001111; +} + +/// Write a nibble into a zero-initialised frame. +constexpr void set_nibble(uint8_t *message, uint8_t nibble, uint8_t value) { + message[nibble / 2] |= (value & 0b00001111) << ((nibble % 2) ? 0 : 4); +} + +// Nibble indices of the state frame fields. +constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; +constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; + +/// Unassigned values keep the current mode, except that OFF becomes HEAT_COOL. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode); + +/// Unassigned values keep the current fan mode. +optional decode_fan_mode(uint8_t fan_field, optional current_mode); + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field); + class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() diff --git a/esphome/components/gdk101/sensor.py b/esphome/components/gdk101/sensor.py index 4ed081a7bef..4fb65d9c256 100644 --- a/esphome/components/gdk101/sensor.py +++ b/esphome/components/gdk101/sensor.py @@ -63,18 +63,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) - if radiation_dose_per_1m := config.get(CONF_RADIATION_DOSE_PER_1M): - sens = await sensor.new_sensor(radiation_dose_per_1m) - cg.add(hub.set_rad_1m_sensor(sens)) - - if radiation_dose_per_10m := config.get(CONF_RADIATION_DOSE_PER_10M): - sens = await sensor.new_sensor(radiation_dose_per_10m) - cg.add(hub.set_rad_10m_sensor(sens)) - - if status_config := config.get(CONF_STATUS): - sens = await sensor.new_sensor(status_config) - cg.add(hub.set_status_sensor(sens)) - - if measurement_duration_config := config.get(CONF_MEASUREMENT_DURATION): - sens = await sensor.new_sensor(measurement_duration_config) - cg.add(hub.set_measurement_duration_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADIATION_DOSE_PER_1M, hub.set_rad_1m_sensor) + await sensors(CONF_RADIATION_DOSE_PER_10M, hub.set_rad_10m_sensor) + await sensors(CONF_STATUS, hub.set_status_sensor) + await sensors(CONF_MEASUREMENT_DURATION, hub.set_measurement_duration_sensor) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index bd6bc5f783a..8adf4e983ca 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,8 +8,7 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -21,7 +20,6 @@ RestoringGlobalsComponent = globals_ns.class_( RestoringGlobalStringComponent = globals_ns.class_( "RestoringGlobalStringComponent", cg.PollingComponent ) -GlobalVarSetAction = globals_ns.class_("GlobalVarSetAction", automation.Action) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" @@ -94,33 +92,19 @@ async def to_code(config: ConfigType) -> None: cg.add(glob.set_name_hash(hash_)) -@automation.register_action( +# A lambda returns the global's own type so `return 42;` reduces to a cast; the value +# itself is raw C++ text. +_VALUE_TYPE = "std::remove_reference_tvalue())>" + +automation.register_apply_action( "globals.set", - GlobalVarSetAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GlobalsComponent), cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField( + CONF_VALUE, "value() = {}", _VALUE_TYPE, const_fn=lambda _, value: value + ), ) -async def globals_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - var = cg.new_Pvariable(action_id, template_arg, paren) - # Use the global's value_type alias as the lambda return type so - # TemplatableFn stores a direct function pointer instead of going through - # the deprecated converting trampoline when the value expression deduces - # to a different type (e.g. int literal assigned to a float global). - value_type = cg.RawExpression(f"{full_id.type}::value_type") - templ = await cg.templatable( - config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression - ) - cg.add(var.set_value(templ)) - return var diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 78d2bc5910b..b7acb065695 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -1,8 +1,10 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include #include namespace esphome::globals { @@ -127,20 +129,6 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction final : public Action { - public: - explicit GlobalVarSetAction(C *parent) : parent_(parent) {} - - using T = typename C::value_type; - - TEMPLATABLE_VALUE(T, value); - - void play(const Ts &...x) override { this->parent_->value() = this->value_.value(x...); } - - protected: - C *parent_; -}; - template T &id(GlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalStringComponent *value) { return value->value(); } diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 956443fab55..80636e29a6e 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -47,6 +47,9 @@ class GPIOBinarySensorStore { class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { public: + // User provided, not "= default": `new(p) GPIOBinarySensor()` would zero-fill .bss that is already zero. + GPIOBinarySensor() {} + // No destructor needed: ESPHome components are created at boot and live forever. // Interrupts are only detached on reboot when memory is cleared anyway. @@ -70,7 +73,7 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon void loop() override; protected: - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; GPIOBinarySensorStore store_; }; diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc7..766cdc4afb3 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d432655a2a4..d231b3d77a5 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -13,18 +13,10 @@ void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); // write state before setup - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); } void GPIOSwitch::dump_config() { LOG_SWITCH("", "GPIO Switch", this); diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 7ed0de7c6f0..e7323e6e937 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -9,6 +9,9 @@ namespace esphome::gpio { class GPIOSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) GPIOSwitch()` would zero-fill .bss that is already zero. + GPIOSwitch() {} + void set_pin(GPIOPin *pin) { pin_ = pin; } // ========== INTERNAL METHODS ========== @@ -25,7 +28,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK FixedVector interlock_; uint32_t interlock_wait_time_{0}; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index f0642d2e8c7..d261c488557 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -57,10 +57,6 @@ void GraphicalDisplayMenu::dump_config() { } } -void GraphicalDisplayMenu::set_display(display::Display *display) { this->display_ = display; } - -void GraphicalDisplayMenu::set_font(display::BaseFont *font) { this->font_ = font; } - void GraphicalDisplayMenu::set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } void GraphicalDisplayMenu::set_background_color(Color background_color) { this->background_color_ = background_color; } diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ccdf3d304c5..13c0f9d73f8 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -38,8 +38,8 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen void setup() override; void dump_config() override; - void set_display(display::Display *display); - void set_font(display::BaseFont *font); + void set_display(display::Display *display) { this->display_ = display; } + void set_font(display::BaseFont *font) { this->font_ = font; } template void set_menu_item_value(V menu_item_value) { this->menu_item_value_ = menu_item_value; } void set_foreground_color(Color foreground_color); void set_background_color(Color background_color); diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index a794e7721f5..e59f186521b 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,17 +5,6 @@ namespace esphome::gree { static const char *const TAG = "gree.climate"; -climate::ClimateTraits GreeClimate::traits() { - auto t = climate_ir::ClimateIR::traits(); - // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported. - if (!this->supports_heat_) { - auto modes = t.get_supported_modes(); - modes.erase(climate::CLIMATE_MODE_HEAT_COOL); - t.set_supported_modes(modes); - } - return t; -} - void GreeClimate::set_model(Model model) { if (model == GREE_YAN) { // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model. @@ -42,23 +31,24 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) { void GreeClimate::transmit_state() { uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00}; + const Model model = this->model_; remote_state[0] = this->fan_speed_() | this->operation_mode_(); remote_state[1] = this->temperature_(); - if (this->model_ == GREE_YAN) { + if (model == GREE_YAN) { remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } - if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { + if (model == GREE_YX1FF || model == GREE_YAG) { remote_state[2] = 0x60; remote_state[3] = 0x50; remote_state[4] = this->vertical_swing_(); } - if (this->model_ == GREE_YAG) { + if (model == GREE_YAG) { remote_state[5] = 0x40; if (this->vertical_swing_() == GREE_VDIR_SWING || this->horizontal_swing_() == GREE_HDIR_SWING) { @@ -66,11 +56,11 @@ void GreeClimate::transmit_state() { } } - if (this->model_ == GREE_YAC || this->model_ == GREE_YAG) { + if (model == GREE_YAC || model == GREE_YAG) { remote_state[4] |= (this->horizontal_swing_() << 4); } - if (this->model_ == GREE_YAA || this->model_ == GREE_YAC || this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 @@ -82,14 +72,13 @@ void GreeClimate::transmit_state() { } } - if (this->model_ == GREE_YAN || this->model_ == GREE_YAA || this->model_ == GREE_YAC || - this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAN || model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { // Merge the mode bits into remote_state[2] // Clear the mode bits (bits 4-7) and OR in the current mode_bits_ remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_; } - if (this->model_ == GREE_YX1FF) { + if (model == GREE_YX1FF) { if (this->fan_speed_() == GREE_FAN_TURBO) { remote_state[2] |= GREE_FAN_TURBO_BIT; } @@ -100,7 +89,7 @@ void GreeClimate::transmit_state() { } // Calculate the checksum - if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF) { + if (model == GREE_YAN || model == GREE_YX1FF) { remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0); } else { remote_state[7] = @@ -115,7 +104,7 @@ void GreeClimate::transmit_state() { data->set_carrier_frequency(GREE_IR_FREQUENCY); data->mark(GREE_HEADER_MARK); - if (this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAC1FB9) { data->space(GREE_YAC1FB9_HEADER_SPACE); } else { data->space(GREE_HEADER_SPACE); @@ -137,7 +126,7 @@ void GreeClimate::transmit_state() { data->space(GREE_ZERO_SPACE); data->mark(GREE_BIT_MARK); - if (this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAC1FB9) { data->space(GREE_YAC1FB9_MESSAGE_SPACE); } else { data->space(GREE_MESSAGE_SPACE); @@ -237,7 +226,7 @@ uint8_t GreeClimate::vertical_swing_() { } uint8_t GreeClimate::temperature_() { - return (uint8_t) roundf(clamp(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)); + return (uint8_t) roundf(clamp(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)) - GREE_TEMP_MIN; } uint8_t GreeClimate::preset_() { diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 2f10be3e6b0..c2616295735 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -77,7 +77,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes -enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; +enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; class GreeClimate final : public climate_ir::ClimateIR { public: @@ -94,7 +94,6 @@ class GreeClimate final : public climate_ir::ClimateIR { protected: // Transmit via IR the state of this climate controller. void transmit_state() override; - climate::ClimateTraits traits() override; uint8_t operation_mode_(); uint8_t fan_speed_(); diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index ae64c049f53..2d44d883230 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -20,28 +20,9 @@ grove_tb6612fng_ns = cg.esphome_ns.namespace("grove_tb6612fng") GROVE_TB6612FNG = grove_tb6612fng_ns.class_( "GroveMotorDriveTB6612FNG", cg.Component, i2c.I2CDevice ) -GROVETB6612FNGMotorRunAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorRunAction", automation.Action -) -GROVETB6612FNGMotorBrakeAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorBrakeAction", automation.Action -) -GROVETB6612FNGMotorStopAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorStopAction", automation.Action -) -GROVETB6612FNGMotorStandbyAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorStandbyAction", automation.Action -) -GROVETB6612FNGMotorNoStandbyAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorNoStandbyAction", automation.Action -) -GROVETB6612FNGMotorChangeAddressAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorChangeAddressAction", automation.Action -) - DIRECTION_TYPE = { "FORWARD": 1, - "BACKWARD": 2, + "BACKWARD": -1, } CONFIG_SCHEMA = ( @@ -61,9 +42,8 @@ async def to_code(config): await i2c.register_i2c_device(var, config) -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.run", - GROVETB6612FNGMotorRunAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), @@ -72,109 +52,52 @@ async def to_code(config): cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), - synchronous=True, + automation.ApplyCall( + "dc_motor_run({}, {} * {})", + ((CONF_CHANNEL, cg.uint8), (CONF_DIRECTION, cg.int16), (CONF_SPEED, cg.uint16)), + ), ) -async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - template_speed = await cg.templatable(config[CONF_SPEED], args, cg.uint16) - cg.add(var.set_channel(template_channel)) - cg.add(var.set_speed(template_speed)) - cg.add(var.set_direction(config[CONF_DIRECTION] == "FORWARD")) - return var +CHANNEL_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), + cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), + } +) - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.break", - GROVETB6612FNGMotorBrakeAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - } - ), - synchronous=True, + CHANNEL_SCHEMA, + automation.ApplyField(CONF_CHANNEL, "dc_motor_brake", cg.uint8), ) -async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_channel)) - return var - - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.stop", - GROVETB6612FNGMotorStopAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - } - ), - synchronous=True, + CHANNEL_SCHEMA, + automation.ApplyField(CONF_CHANNEL, "dc_motor_stop", cg.uint8), ) -async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_channel)) - return var - - -@automation.register_action( - "grove_tb6612fng.standby", - GROVETB6612FNGMotorStandbyAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - } - ), - synchronous=True, +PARENT_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), + } ) -async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "grove_tb6612fng.no_standby", - GROVETB6612FNGMotorNoStandbyAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - } - ), - synchronous=True, +automation.register_apply_action( + "grove_tb6612fng.standby", PARENT_SCHEMA, automation.ApplyCall("standby()") ) -async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +automation.register_apply_action( + "grove_tb6612fng.no_standby", PARENT_SCHEMA, automation.ApplyCall("not_standby()") +) - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.change_address", - GROVETB6612FNGMotorChangeAddressAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), cv.Required(CONF_ADDRESS): cv.i2c_address, } ), - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "set_i2c_addr", cg.uint8), ) -async def grove_tb6612fng_change_address_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_channel = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_channel)) - return var diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index a8648025b98..08a3ac2374e 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/components/i2c/i2c.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" // #include "esphome/core/helpers.h" @@ -161,61 +160,4 @@ class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { uint8_t buffer_[16]; }; -template -class GROVETB6612FNGMotorRunAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint16_t, speed) - - void set_direction(bool forward) { this->forward_ = forward; } - - void play(const Ts &...x) override { - auto channel = this->channel_.value(x...); - int16_t speed = this->speed_.value(x...); - if (!this->forward_) { - speed = -speed; - } - this->parent_->dc_motor_run(channel, speed); - } - - protected: - bool forward_{true}; -}; - -template -class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - - void play(const Ts &...x) override { this->parent_->dc_motor_brake(this->channel_.value(x...)); } -}; - -template -class GROVETB6612FNGMotorStopAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - - void play(const Ts &...x) override { this->parent_->dc_motor_stop(this->channel_.value(x...)); } -}; - -template -class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->standby(); } -}; - -template -class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->not_standby(); } -}; - -template -class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, address) - - void play(const Ts &...x) override { this->parent_->set_i2c_addr(this->address_.value(x...)); } -}; - } // namespace esphome::grove_tb6612fng diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 2e2b2187307..af323e6b07d 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -22,6 +22,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, ) @@ -33,7 +34,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" @@ -177,33 +177,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) - if CONF_INVERTER_STATUS in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_STATUS]) - cg.add(var.set_inverter_status_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_grid_frequency_sensor(sens)) - - if CONF_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_ACTIVE_POWER]) - cg.add(var.set_grid_active_power_sensor(sens)) - - if CONF_PV_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_PV_ACTIVE_POWER]) - cg.add(var.set_pv_active_power_sensor(sens)) - - if CONF_ENERGY_PRODUCTION_DAY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY_PRODUCTION_DAY]) - cg.add(var.set_today_production_sensor(sens)) - - if CONF_TOTAL_ENERGY_PRODUCTION in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_ENERGY_PRODUCTION]) - cg.add(var.set_total_energy_production_sensor(sens)) - - if CONF_INVERTER_MODULE_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_MODULE_TEMP]) - cg.add(var.set_inverter_module_temp_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_INVERTER_STATUS, var.set_inverter_status_sensor) + await sensors(CONF_FREQUENCY, var.set_grid_frequency_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_grid_active_power_sensor) + await sensors(CONF_PV_ACTIVE_POWER, var.set_pv_active_power_sensor) + await sensors(CONF_ENERGY_PRODUCTION_DAY, var.set_today_production_sensor) + await sensors(CONF_TOTAL_ENERGY_PRODUCTION, var.set_total_energy_production_sensor) + await sensors(CONF_INVERTER_MODULE_TEMP, var.set_inverter_module_temp_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h deleted file mode 100644 index a81fd4bdb7d..00000000000 --- a/esphome/components/haier/automation.h +++ /dev/null @@ -1,128 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "haier_base.h" -#include "hon_climate.h" - -namespace esphome::haier { - -template class DisplayOnAction final : public Action { - public: - DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_display_state(true); } - - protected: - HaierClimateBase *parent_; -}; - -template class DisplayOffAction final : public Action { - public: - DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_display_state(false); } - - protected: - HaierClimateBase *parent_; -}; - -template class BeeperOnAction final : public Action { - public: - BeeperOnAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_beeper_state(true); } - - protected: - HonClimate *parent_; -}; - -template class BeeperOffAction final : public Action { - public: - BeeperOffAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_beeper_state(false); } - - protected: - HonClimate *parent_; -}; - -template class VerticalAirflowAction final : public Action { - public: - VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) - void play(const Ts &...x) { this->parent_->set_vertical_airflow(this->direction_.value(x...)); } - - protected: - HonClimate *parent_; -}; - -template class HorizontalAirflowAction final : public Action { - public: - HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) - void play(const Ts &...x) { this->parent_->set_horizontal_airflow(this->direction_.value(x...)); } - - protected: - HonClimate *parent_; -}; - -template class HealthOnAction final : public Action { - public: - HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_health_mode(true); } - - protected: - HaierClimateBase *parent_; -}; - -template class HealthOffAction final : public Action { - public: - HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_health_mode(false); } - - protected: - HaierClimateBase *parent_; -}; - -template class StartSelfCleaningAction final : public Action { - public: - StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->start_self_cleaning(); } - - protected: - HonClimate *parent_; -}; - -template class StartSteriCleaningAction final : public Action { - public: - StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } - - protected: - HonClimate *parent_; -}; - -template class PowerOnAction final : public Action { - public: - PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->send_power_on_command(); } - - protected: - HaierClimateBase *parent_; -}; - -template class PowerOffAction final : public Action { - public: - PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->send_power_off_command(); } - - protected: - HaierClimateBase *parent_; -}; - -template class PowerToggleAction final : public Action { - public: - PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->toggle_power(); } - - protected: - HaierClimateBase *parent_; -}; - -} // namespace esphome::haier diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 70ae36f5281..5c4d498912d 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_BEEPER, CONF_CURRENT_TEMPERATURE, CONF_DISPLAY, - CONF_ID, CONF_LEVEL, CONF_LOGGER, CONF_LOGS, @@ -254,23 +253,6 @@ CONFIG_SCHEMA = cv.All( ) -# Actions -DisplayOnAction = haier_ns.class_("DisplayOnAction", automation.Action) -DisplayOffAction = haier_ns.class_("DisplayOffAction", automation.Action) -BeeperOnAction = haier_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = haier_ns.class_("BeeperOffAction", automation.Action) -StartSelfCleaningAction = haier_ns.class_("StartSelfCleaningAction", automation.Action) -StartSteriCleaningAction = haier_ns.class_( - "StartSteriCleaningAction", automation.Action -) -VerticalAirflowAction = haier_ns.class_("VerticalAirflowAction", automation.Action) -HorizontalAirflowAction = haier_ns.class_("HorizontalAirflowAction", automation.Action) -HealthOnAction = haier_ns.class_("HealthOnAction", automation.Action) -HealthOffAction = haier_ns.class_("HealthOffAction", automation.Action) -PowerOnAction = haier_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = haier_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = haier_ns.class_("PowerToggleAction", automation.Action) - HAIER_BASE_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(HaierClimateBase), @@ -284,62 +266,35 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "climate.haier.display_on", - DisplayOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.display_off", - DisplayOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def display_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _schema, _call in ( + ("climate.haier.display_on", HAIER_BASE_ACTION_SCHEMA, "set_display_state(true)"), + ("climate.haier.display_off", HAIER_BASE_ACTION_SCHEMA, "set_display_state(false)"), + ("climate.haier.beeper_on", HAIER_HON_BASE_ACTION_SCHEMA, "set_beeper_state(true)"), + ( + "climate.haier.beeper_off", + HAIER_HON_BASE_ACTION_SCHEMA, + "set_beeper_state(false)", + ), + ( + "climate.haier.start_self_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_self_cleaning()", + ), + ( + "climate.haier.start_steri_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_steri_cleaning()", + ), + ("climate.haier.health_on", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(true)"), + ("climate.haier.health_off", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(false)"), + ("climate.haier.power_on", HAIER_BASE_ACTION_SCHEMA, "send_power_on_command()"), + ("climate.haier.power_off", HAIER_BASE_ACTION_SCHEMA, "send_power_off_command()"), + ("climate.haier.power_toggle", HAIER_BASE_ACTION_SCHEMA, "toggle_power()"), +): + automation.register_apply_action(_name, _schema, automation.ApplyCall(_call)) - -@automation.register_action( - "climate.haier.beeper_on", - BeeperOnAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.beeper_off", - BeeperOffAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def beeper_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -# Start self cleaning or steri-cleaning action action -@automation.register_action( - "climate.haier.start_self_cleaning", - StartSelfCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.start_steri_cleaning", - StartSteriCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def start_cleaning_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -# Set vertical airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_vertical_airflow", - VerticalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -348,22 +303,13 @@ async def start_cleaning_to_code(config, action_id, template_arg, args): ), } ), - synchronous=True, + automation.ApplyField( + CONF_VERTICAL_AIRFLOW, "set_vertical_airflow", AirflowVerticalDirection + ), ) -async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_VERTICAL_AIRFLOW], args, AirflowVerticalDirection - ) - cg.add(var.set_direction(template_)) - return var - -# Set horizontal airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_horizontal_airflow", - HorizontalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -372,56 +318,10 @@ async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, ar ), } ), - synchronous=True, + automation.ApplyField( + CONF_HORIZONTAL_AIRFLOW, "set_horizontal_airflow", AirflowHorizontalDirection + ), ) -async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_HORIZONTAL_AIRFLOW], args, AirflowHorizontalDirection - ) - cg.add(var.set_direction(template_)) - return var - - -@automation.register_action( - "climate.haier.health_on", - HealthOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.health_off", - HealthOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def health_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "climate.haier.power_on", - PowerOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.power_off", - PowerOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.power_toggle", - PowerToggleAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def power_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config) -> None: diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 48f72dc16b9..87f9331d555 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -190,8 +190,6 @@ void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); } -void HaierClimateBase::set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } - void HaierClimateBase::send_custom_command(const haier_protocol::HaierMessage &message) { this->action_request_ = PendingAction({ActionRequest::SEND_CUSTOM_COMMAND, message}); } diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index db4c1abceb3..18ddbcc1cc5 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -71,7 +71,7 @@ class HaierClimateBase : public esphome::Component, }; bool can_send_message() const { return haier_protocol_.get_outgoing_queue_size() == 0; }; void set_answer_timeout(uint32_t timeout); - void set_send_wifi(bool send_wifi); + void set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } void send_custom_command(const haier_protocol::HaierMessage &message); template void add_status_message_callback(F &&callback) { this->status_message_callback_.add(std::forward(callback)); diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index dcea1afd04f..d2db05381d8 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_DEGREES, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_MINUTE, UNIT_VOLT, UNIT_VOLT_AMPS_REACTIVE, @@ -36,7 +37,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" @@ -229,65 +229,24 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_ACTIVE_POWER]) - cg.add(var.set_active_power_sensor(sens)) - - if CONF_REACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_REACTIVE_POWER]) - cg.add(var.set_reactive_power_sensor(sens)) - - if CONF_ENERGY_PRODUCTION_DAY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY_PRODUCTION_DAY]) - cg.add(var.set_today_production_sensor(sens)) - - if CONF_TOTAL_ENERGY_PRODUCTION in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_ENERGY_PRODUCTION]) - cg.add(var.set_total_energy_production_sensor(sens)) - - if CONF_TOTAL_GENERATION_TIME in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_GENERATION_TIME]) - cg.add(var.set_total_generation_time_sensor(sens)) - - if CONF_TODAY_GENERATION_TIME in config: - sens = await sensor.new_sensor(config[CONF_TODAY_GENERATION_TIME]) - cg.add(var.set_today_generation_time_sensor(sens)) - - if CONF_INVERTER_MODULE_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_MODULE_TEMP]) - cg.add(var.set_inverter_module_temp_sensor(sens)) - - if CONF_INVERTER_INNER_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_INNER_TEMP]) - cg.add(var.set_inverter_inner_temp_sensor(sens)) - - if CONF_INVERTER_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_BUS_VOLTAGE]) - cg.add(var.set_inverter_bus_voltage_sensor(sens)) - - if CONF_INSULATION_OF_PV_N_TO_GROUND in config: - sens = await sensor.new_sensor(config[CONF_INSULATION_OF_PV_N_TO_GROUND]) - cg.add(var.set_insulation_pv_n_to_ground_sensor(sens)) - - if CONF_GFCI_VALUE in config: - sens = await sensor.new_sensor(config[CONF_GFCI_VALUE]) - cg.add(var.set_gfci_value_sensor(sens)) - - if CONF_DCI_OF_R in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_R]) - cg.add(var.set_dci_of_r_sensor(sens)) - - if CONF_DCI_OF_S in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_S]) - cg.add(var.set_dci_of_s_sensor(sens)) - - if CONF_DCI_OF_T in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_T]) - cg.add(var.set_dci_of_t_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_ENERGY_PRODUCTION_DAY, var.set_today_production_sensor) + await sensors(CONF_TOTAL_ENERGY_PRODUCTION, var.set_total_energy_production_sensor) + await sensors(CONF_TOTAL_GENERATION_TIME, var.set_total_generation_time_sensor) + await sensors(CONF_TODAY_GENERATION_TIME, var.set_today_generation_time_sensor) + await sensors(CONF_INVERTER_MODULE_TEMP, var.set_inverter_module_temp_sensor) + await sensors(CONF_INVERTER_INNER_TEMP, var.set_inverter_inner_temp_sensor) + await sensors(CONF_INVERTER_BUS_VOLTAGE, var.set_inverter_bus_voltage_sensor) + await sensors( + CONF_INSULATION_OF_PV_N_TO_GROUND, var.set_insulation_pv_n_to_ground_sensor + ) + await sensors(CONF_GFCI_VALUE, var.set_gfci_value_sensor) + await sensors(CONF_DCI_OF_R, var.set_dci_of_r_sensor) + await sensors(CONF_DCI_OF_S, var.set_dci_of_s_sensor) + await sensors(CONF_DCI_OF_T, var.set_dci_of_t_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 2cf1693b472..e862760e6ae 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -7,14 +7,11 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DECAY_MODE, CONF_ENABLE_PIN, - CONF_ID, CONF_PIN_A, CONF_PIN_B, CONF_PRESET_MODES, CONF_SPEED_COUNT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import hbridge_ns @@ -30,9 +27,6 @@ DECAY_MODE_OPTIONS = { "FAST": DecayMode.DECAY_MODE_FAST, } -# Actions -BrakeAction = hbridge_ns.class_("BrakeAction", automation.Action) - CONFIG_SCHEMA = ( fan.fan_schema(HBridgeFan) .extend( @@ -51,20 +45,11 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_apply_action( "fan.hbridge.brake", - BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), - synchronous=True, + automation.ApplyCall("brake()"), ) -async def fan_hbridge_brake_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 187b6d2a97f..c786f606740 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" #include "esphome/components/fan/fan.h" @@ -46,13 +45,4 @@ class HBridgeFan final : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction final : public Action { - public: - explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->brake(); } - - HBridgeFan *parent_; -}; - } // namespace esphome::hbridge diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index 681dffe4f6c..d3399c8a2d1 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -26,11 +25,4 @@ class HC8Component final : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, baseline) - - void play(const Ts &...x) override { this->parent_->calibrate(this->baseline_.value(x...)); } -}; - } // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 8a19cce8d18..05d8e233ef3 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,15 +12,12 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] hc8_ns = cg.esphome_ns.namespace("hc8") HC8Component = hc8_ns.class_("HC8Component", cg.PollingComponent, uart.UARTDevice) -HC8CalibrateAction = hc8_ns.class_("HC8CalibrateAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -73,20 +70,8 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "hc8.calibrate", - HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_BASELINE, "calibrate", cg.uint16), ) -async def hc8_calibration_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]) - template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) - cg.add(var.set_baseline(template_)) - return var diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index cc5343ee89c..0cf8b7a3868 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -48,21 +47,4 @@ class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, power) - TEMPLATABLE_VALUE(uint32_t, duration) - - void play(const Ts &...x) override { - auto power_val = this->power_.value(x...); - auto duration_val = this->duration_.value(x...); - this->parent_->start_heater(power_val, duration_val); - } -}; - -template class HeaterOffAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_heater(); } -}; - } // namespace esphome::hdc302x diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 6d91c3df7c2..2686ba53223 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -18,8 +18,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -37,10 +35,6 @@ POWER_MODE_OPTIONS = { "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, } -# Actions -HeaterOnAction = hdc302x_ns.class_("HeaterOnAction", automation.Action) -HeaterOffAction = hdc302x_ns.class_("HeaterOffAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -118,39 +112,14 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "hdc302x.heater_on", - HeaterOnAction, HDC302X_HEATER_ON_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "start_heater({}, {})", ((CONF_POWER, cg.uint16), (CONF_DURATION, cg.uint32)) + ), ) -async def hdc302x_heater_on_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]) - template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) - cg.add(var.set_power(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(template_)) - return var - -@automation.register_action( - "hdc302x.heater_off", - HeaterOffAction, - HDC302X_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "hdc302x.heater_off", HDC302X_ACTION_SCHEMA, automation.ApplyCall("stop_heater()") ) -async def hdc302x_heater_off_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]) - return var diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index c1aa81f6d42..b8040e55794 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -27,11 +27,6 @@ HlkFm22xComponent = hlk_fm22x_ns.class_( "HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice ) -EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action) -DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action) -DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action) -ScanAction = hlk_fm22x_ns.class_("ScanAction", automation.Action) -ResetAction = hlk_fm22x_ns.class_("ResetAction", automation.Action) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -101,9 +96,8 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.enroll", - EnrollmentAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), @@ -112,22 +106,14 @@ async def to_code(config): }, key=CONF_NAME, ), - synchronous=True, + automation.ApplyCall( + "enroll_face({}, static_cast({}))", + ((CONF_NAME, cg.std_string), (CONF_DIRECTION, cg.uint8)), + ), ) -async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_NAME], args, cg.std_string) - cg.add(var.set_name(template_)) - template_ = await cg.templatable(config[CONF_DIRECTION], args, cg.uint8) - cg.add(var.set_direction(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.delete", - DeleteAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), @@ -135,60 +121,37 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): }, key=CONF_FACE_ID, ), - synchronous=True, + automation.ApplyField(CONF_FACE_ID, "delete_face", cg.int16), ) -async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FACE_ID], args, cg.int16) - cg.add(var.set_face_id(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.delete_all", - DeleteAllAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("delete_all_faces()"), ) -async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.scan", - ScanAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("scan_face()"), ) -async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.reset", - ResetAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) -async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 3bdf6e2c71c..8b219f3ed2e 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -141,41 +141,4 @@ class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, name) - TEMPLATABLE_VALUE(uint8_t, direction) - - void play(const Ts &...x) override { - auto name = this->name_.value(x...); - auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...); - this->parent_->enroll_face(name, direction); - } -}; - -template class DeleteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(int16_t, face_id) - - void play(const Ts &...x) override { - auto face_id = this->face_id_.value(x...); - this->parent_->delete_face(face_id); - } -}; - -template class DeleteAllAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->delete_all_faces(); } -}; - -template class ScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->scan_face(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - } // namespace esphome::hlk_fm22x diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 384477be3df..5099dcc94ca 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -107,18 +107,11 @@ async def to_code(config: ConfigType) -> None: cf1 = await cg.gpio_pin_expression(config[CONF_CF1_PIN]) cg.add(var.set_cf1_pin(cf1)) - if CONF_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_VOLTAGE]) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY]) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) cg.add(var.set_initial_mode(INITIAL_MODES[config[CONF_INITIAL_MODE]])) diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index 7b069d85d0f..f2a8ffb2161 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -79,20 +79,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if apparent_power_config := config.get(CONF_APPARENT_POWER): - sens = await sensor.new_sensor(apparent_power_config) - cg.add(var.set_apparent_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 2fa82b2710a..79fb81ea5f5 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -92,17 +92,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) # Remove before 2026.12.0 if CONF_AQI in config: diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index a2e1f8054ad..229d6c8b623 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -136,15 +136,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(auto_data_rate(config))) cg.add(var.set_range(config[CONF_RANGE])) - if CONF_FIELD_STRENGTH_X in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X]) - cg.add(var.set_x_sensor(sens)) - if CONF_FIELD_STRENGTH_Y in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y]) - cg.add(var.set_y_sensor(sens)) - if CONF_FIELD_STRENGTH_Z in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z]) - cg.add(var.set_z_sensor(sens)) - if CONF_HEADING in config: - sens = await sensor.new_sensor(config[CONF_HEADING]) - cg.add(var.set_heading_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) + await sensors(CONF_HEADING, var.set_heading_sensor) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 4aa2c79bb15..9ab7014e0e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,6 +1,9 @@ #include "hoermann_hcp.h" +#include + #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hoermann_hcp { @@ -60,6 +63,61 @@ static bool is_moving(DoorState state) { } } +#ifdef USE_HOERMANN_HCP_IDENTITY +// The command byte of a status poll. Only its answer can carry a request. +static constexpr uint8_t STATUS_COMMAND = 0x03; +// A status answer with this code in the low byte of its second register asks the bus controller for a value, +// named in the high byte of the third. +static constexpr uint8_t ANSWER_REQUEST = 0x22; +static constexpr uint8_t REQUEST_SERIAL = 0x05; +static constexpr uint8_t REQUEST_FIRMWARE = 0x06; +// Each request goes out in one answer, up to this many times, this far apart. +static constexpr uint8_t IDENTITY_MAX_ATTEMPTS = 3; +static constexpr uint32_t IDENTITY_RETRY_MS = 30000; +// The value comes back as a payload transfer: this command in the low byte of the first command register, a sub +// code in the high byte of the second, the payload from the third on. +static constexpr uint8_t TRANSFER_COMMAND = 0x04; +static constexpr uint8_t TRANSFER_SUB_SERIAL = 0x0C; +static constexpr uint8_t TRANSFER_SUB_FIRMWARE = 0x0D; +static constexpr uint8_t TRANSFER_ACK = 0xFD; +static constexpr size_t TRANSFER_PAYLOAD_REG = 2; +// Marks the first half of the serial number in the counter byte, and is not part of the count. +static constexpr uint8_t COUNTER_FIRST_HALF = 0x80; +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. It is as long +// as a second half, so one copy path serves both. +static constexpr size_t SERIAL_SINGLE_FRAME_REGS = 6; +static_assert(SERIAL_SINGLE_FRAME_REGS == SERIAL_SECOND_HALF_REGS, "one copy path serves both serial frames"); + +// Registers hold two payload bytes each, high byte first. +static void copy_payload(const modbus::RegisterValues ®isters, size_t count, char *out) { + for (size_t i = 0; i < count; i++) { + const uint16_t value = registers[TRANSFER_PAYLOAD_REG + i]; + out[2 * i] = static_cast(value >> 8); + out[2 * i + 1] = static_cast(value); + } +} + +// Length of the printable text at the start, without trailing spaces. The padding after it varies. +static size_t text_length(const char *text, size_t len) { + size_t at = 0; + while (at < len && text[at] >= 0x20 && text[at] <= 0x7E) + at++; + while (at > 0 && text[at - 1] == ' ') + at--; + return at; +} + +static void terminate_text(char *text, size_t len) { text[text_length(text, len)] = '\0'; } + +// dump_config() is replayed to remote log clients, so they see the outcome of the exchange at boot. +static void log_identity_value(text_sensor::TextSensor *sensor) { + if (sensor != nullptr) { + ESP_LOGCONFIG(TAG, " Value: %s", + sensor->has_state() ? sensor->get_state().c_str() : LOG_STR_LITERAL("not received")); + } +} +#endif + void HoermannHcp::update() { const uint32_t now = millis(); // Time out the connection flag if the bus controller stopped polling. @@ -91,6 +149,9 @@ void HoermannHcp::update() { ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); this->forget_light_toggles_(); } +#ifdef USE_HOERMANN_HCP_IDENTITY + this->publish_identity_(); +#endif if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -102,6 +163,12 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_HOERMANN_HCP_IDENTITY + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); + log_identity_value(this->serial_number_text_sensor_); + LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_); + log_identity_value(this->version_text_sensor_); +#endif } modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, @@ -113,6 +180,14 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add this->record_response_(); +#ifdef USE_HOERMANN_HCP_IDENTITY + // Acknowledge the transfer taken by the write half of this frame. + if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) { + this->push_transfer_answer_(registers, number_of_registers); + return {}; + } +#endif + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message // counter (high byte) and command (low byte). The read length identifies which internal block is requested. const uint16_t counter = this->command_reg_value_ & 0xFF00; @@ -125,6 +200,9 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add registers.push_back(static_cast(0x0001 | command)); this->push_command_registers_(registers); push_zeros(registers, 4); +#ifdef USE_HOERMANN_HCP_IDENTITY + this->add_identity_request_(registers, command); +#endif break; case 2: // Empty command request. @@ -156,6 +234,9 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, // command byte back from STATE_REG. The hub always runs the write before the read within one request. this->record_response_(); this->command_reg_value_ = registers[0]; +#ifdef USE_HOERMANN_HCP_IDENTITY + this->transfer_answer_counter_ = this->take_identity_transfer_(registers); +#endif return {}; } @@ -217,6 +298,155 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { registers.push_back(command->released_value_2); } +#ifdef USE_HOERMANN_HCP_IDENTITY +void HoermannHcp::add_identity_request_(modbus::RegisterValues ®isters, uint16_t command) { + if (static_cast(this->command_reg_value_) != STATUS_COMMAND) + return; + // Like Hoermann's own bus accessory, only after one ordinary answer. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE) { + if (this->serial_number_text_sensor_ != nullptr || this->version_text_sensor_ != nullptr) + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_SERIAL); + return; + } + // Uses the registers of a key press, so it waits while one is pending. + if (this->next_command_ != nullptr || registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_()) + return; + registers[1] = static_cast(ANSWER_REQUEST | command); + registers[2] = encode_uint16(this->identity_request_(), 0); +} + +void HoermannHcp::arm_identity_request_(IdentityPhase phase) { + this->identity_phase_ = phase; + this->identity_attempts_ = 0; +} + +bool HoermannHcp::take_identity_request_() { + const uint8_t request = this->identity_request_(); + if (request == 0) + return false; + const uint32_t now = millis(); + if (this->identity_attempts_ != 0 && now - this->identity_asked_at_ <= IDENTITY_RETRY_MS) + return false; + if (this->identity_attempts_ >= IDENTITY_MAX_ATTEMPTS) { + this->identity_unanswered_ = request; + if (request == REQUEST_SERIAL) { + // Still ask for the firmware version, and drop a leftover first half. + this->serial_number_[0] = '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + } else { + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + } + return false; + } + this->identity_attempts_++; + this->identity_asked_at_ = now; + return true; +} + +uint8_t HoermannHcp::take_identity_transfer_(const modbus::RegisterValues ®isters) { + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE || registers.size() < TRANSFER_PAYLOAD_REG || + static_cast(registers[0]) != TRANSFER_COMMAND) + return NO_TRANSFER_ANSWER; + const uint8_t counter = static_cast(registers[0] >> 8); + const uint8_t sub_code = static_cast(registers[1] >> 8); + if (sub_code != TRANSFER_SUB_SERIAL && sub_code != TRANSFER_SUB_FIRMWARE) + return NO_TRANSFER_ANSWER; + // Acknowledged whether kept or not, as Hoermann's own bus accessory does. What was not kept is asked for again. + const uint8_t answer = counter & ~COUNTER_FIRST_HALF; + // Only an answer to the request that went out is kept. + const uint8_t request = sub_code == TRANSFER_SUB_SERIAL ? REQUEST_SERIAL : REQUEST_FIRMWARE; + if (this->identity_attempts_ == 0 || this->identity_request_() != request) + return answer; + + const size_t payload_regs = registers.size() - TRANSFER_PAYLOAD_REG; + if (request == REQUEST_FIRMWARE) { + const size_t regs = std::min(payload_regs, FIRMWARE_REGS); + copy_payload(registers, regs, this->firmware_version_); + if (regs == FIRMWARE_REGS && text_length(this->firmware_version_, 2 * regs) != 0) { + terminate_text(this->firmware_version_, 2 * regs); + // Clear an earlier unreadable answer, so this one is shown. + this->firmware_unreadable_ = false; + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + // Left as received for update() to log. + this->firmware_unreadable_ = true; + this->firmware_unreadable_len_ = 2 * regs; + // A short answer is asked for again; unreadable text would come back the same. + if (regs == FIRMWARE_REGS) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + if ((counter & COUNTER_FIRST_HALF) != 0) { + // Even a half too short to keep says the number comes in two halves; a first half already kept stays kept. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT; + if (payload_regs >= SERIAL_FIRST_HALF_REGS) { + copy_payload(registers, SERIAL_FIRST_HALF_REGS, this->serial_number_); + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF; + } + return answer; + } + // Without a half marker seen, the frame is the whole number; after one, it can only be the second half. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT || payload_regs < SERIAL_SINGLE_FRAME_REGS) + return answer; + const size_t at = + this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF ? 2 * SERIAL_FIRST_HALF_REGS : 0; + copy_payload(registers, SERIAL_SINGLE_FRAME_REGS, this->serial_number_ + at); + terminate_text(this->serial_number_, at + 2 * SERIAL_SINGLE_FRAME_REGS); + this->serial_unreadable_ = this->serial_number_[0] == '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + return answer; +} + +void HoermannHcp::push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers) { + const uint16_t answer[] = {encode_uint16(this->transfer_answer_counter_, 0), + encode_uint16(TRANSFER_COMMAND, TRANSFER_ACK)}; + this->transfer_answer_counter_ = NO_TRANSFER_ANSWER; + for (uint16_t i = 0; i < number_of_registers; i++) + registers.push_back(i < 2 ? answer[i] : 0x0000); +} + +void HoermannHcp::publish_identity_() { + // Buffers are cleared once published, so each value goes out once. The serial number is whole once the firmware + // version is requested. + if (this->serial_number_text_sensor_ != nullptr && this->identity_request_() != REQUEST_SERIAL && + this->serial_number_[0] != '\0') { + this->serial_number_text_sensor_->publish_state(this->serial_number_); + this->serial_number_[0] = '\0'; + } + if (this->serial_unreadable_) { + this->serial_unreadable_ = false; + ESP_LOGW(TAG, "Unreadable serial number"); + } + // Checked first: the buffer then holds raw bytes, not text. + if (this->firmware_unreadable_) { + this->firmware_unreadable_ = false; + const uint8_t len = this->firmware_unreadable_len_; + // All zeros: the motor does not report a version (index B1 seen). + if (len == 2 * FIRMWARE_REGS && + std::all_of(this->firmware_version_, this->firmware_version_ + len, [](char c) { return c == '\0'; })) { + ESP_LOGD(TAG, "Motor does not report its firmware version"); + } else { + char hex[format_hex_size(2 * FIRMWARE_REGS)]; + ESP_LOGW(TAG, "Unreadable firmware version (%u bytes): %s", len, + format_hex_to(hex, reinterpret_cast(this->firmware_version_), len)); + } + this->firmware_version_[0] = '\0'; + } + if (this->version_text_sensor_ != nullptr && this->firmware_version_[0] != '\0') { + this->version_text_sensor_->publish_state(this->firmware_version_); + this->firmware_version_[0] = '\0'; + } + if (this->identity_unanswered_ != 0) { + ESP_LOGW(TAG, "No usable %s received", + this->identity_unanswered_ == REQUEST_SERIAL ? LOG_STR_LITERAL("serial number") + : LOG_STR_LITERAL("firmware version")); + this->identity_unanswered_ = 0; + } +} +#endif + void HoermannHcp::on_position_reg_(uint16_t value) { // Low byte: current position. const uint8_t position = static_cast(value); @@ -371,6 +601,9 @@ void HoermannHcp::set_valid_(bool valid) { this->forget_light_toggles_(); // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. this->set_light_seen_(false); + // The same holds for the door. The next broadcast is decoded even if it repeats the last one. + this->door_state_seen_ = false; + this->prev_state_reg_ = 0xFFFF; this->short_broadcast_logged_ = false; } @@ -410,6 +643,11 @@ void HoermannHcp::forget_light_toggles_() { } void HoermannHcp::set_door_state_(DoorState state) { + // The first state may equal the default, so being seen is a change of its own. + if (!this->door_state_seen_) { + this->door_state_seen_ = true; + this->changed_ = true; + } if (this->door_state_ == state) return; this->door_state_ = state; diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 83be385c7b1..9ebb717d0c4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -4,7 +4,11 @@ #include "esphome/components/modbus/modbus.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_HOERMANN_HCP_IDENTITY +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::hoermann_hcp { @@ -21,6 +25,28 @@ enum class DoorState : uint8_t { STOPPED, }; +#ifdef USE_HOERMANN_HCP_IDENTITY +// Payload registers of each value, two bytes each. +static constexpr size_t SERIAL_FIRST_HALF_REGS = 7; +static constexpr size_t SERIAL_SECOND_HALF_REGS = 6; +static constexpr size_t FIRMWARE_REGS = 6; +// Counter of the transfer the read half of the frame acknowledges. Real counters have the 0x80 half marker +// stripped, so this value never occurs. +static constexpr uint8_t NO_TRANSFER_ANSWER = 0xFF; + +// Where the identity exchange stands. The low nibble is the code the motor is asked with, 0 while nothing is +// outstanding. Like Hoermann's own bus accessory, the first status answer stays ordinary. Older motors (index B1 +// seen) send the whole serial number in one frame, without the half marker. +enum class IdentityPhase : uint8_t { + IDENTITY_PHASE_IDLE = 0x00, + IDENTITY_PHASE_DONE = 0x10, + IDENTITY_PHASE_SERIAL = 0x05, // A plain frame is the whole serial number. + IDENTITY_PHASE_SERIAL_SPLIT = 0x15, // A half marker was seen, so a plain frame can only be the second half. + IDENTITY_PHASE_SERIAL_SECOND_HALF = 0x25, // The first half is in. + IDENTITY_PHASE_FIRMWARE = 0x06, +}; +#endif + // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a // short delay the released value. Each half also carries a second register, which names the buttons that do // not fit into the first. @@ -35,6 +61,12 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_HOERMANN_HCP_IDENTITY + // The motor is asked for these only when one of them is configured. + SUB_TEXT_SENSOR(serial_number) + SUB_TEXT_SENSOR(version) +#endif + public: void update() override; void dump_config() override; @@ -63,6 +95,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } + // False until a broadcast has carried a state the door is known to report. Bus traffic alone makes the + // connection valid, so get_door_state() would still be its default. + bool is_door_state_known() const { return this->door_state_seen_; } float get_current_position() const { return this->current_position_; } bool is_valid() const { return this->valid_; } bool is_light_on() const { return this->light_on_; } @@ -95,6 +130,21 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); void on_light_reg_(uint16_t value); +#ifdef USE_HOERMANN_HCP_IDENTITY + // Puts a due request into a status answer. + void add_identity_request_(modbus::RegisterValues ®isters, uint16_t command); + void arm_identity_request_(IdentityPhase phase); + uint8_t identity_request_() const { return static_cast(this->identity_phase_) & 0x0F; } + // True when the request is due, counting the attempt. Gives up after the last one. + bool take_identity_request_(); + // Takes a value the motor hands over as a payload transfer. Returns the counter to acknowledge it with, kept or + // not, or NO_TRANSFER_ANSWER when the frame was something else. + uint8_t take_identity_transfer_(const modbus::RegisterValues ®isters); + // Acknowledges the transfer taken by the write half of the same frame. + void push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers); + // Runs from update(), outside the bus callbacks. + void publish_identity_(); +#endif void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -145,7 +195,24 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool changed_{false}; bool light_on_{false}; bool light_seen_{false}; + bool door_state_seen_{false}; bool short_broadcast_logged_{false}; + +#ifdef USE_HOERMANN_HCP_IDENTITY + uint32_t identity_asked_at_{0}; + IdentityPhase identity_phase_{IdentityPhase::IDENTITY_PHASE_IDLE}; + uint8_t identity_attempts_{0}; + // The request given up on, for update() to report. + uint8_t identity_unanswered_{0}; + uint8_t transfer_answer_counter_{NO_TRANSFER_ANSWER}; + // Length of an unreadable firmware version left in firmware_version_. + uint8_t firmware_unreadable_len_{0}; + // A serial number arrived without text, for update() to log. + bool serial_unreadable_{false}; + bool firmware_unreadable_{false}; + char serial_number_[2 * (SERIAL_FIRST_HALF_REGS + SERIAL_SECOND_HALF_REGS) + 1]{}; + char firmware_version_[2 * FIRMWARE_REGS + 1]{}; +#endif }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 00000000000..2f038025b9a --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,50 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_DOOR_STATE = "door_state" +CONF_SERIAL_NUMBER = "serial_number" + +HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( + "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_DOOR_STATE): text_sensor.text_sensor_schema( + HoermannHcpDoorStateTextSensor, icon="mdi:garage" + ).extend(cv.COMPONENT_SCHEMA), + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema( + icon="mdi:data-matrix", entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + icon=ICON_CHIP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ), + cv.has_at_least_one_key(CONF_DOOR_STATE, CONF_SERIAL_NUMBER, CONF_VERSION), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + if (conf := config.get(CONF_DOOR_STATE)) is not None: + var = await text_sensor.new_text_sensor(conf, parent) + await cg.register_component(var, conf) + # Only the identity sensors need the exchange with the motor compiled in. + if CONF_SERIAL_NUMBER in config or CONF_VERSION in config: + cg.add_define("USE_HOERMANN_HCP_IDENTITY") + if (conf := config.get(CONF_SERIAL_NUMBER)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_serial_number_text_sensor(sens)) + if (conf := config.get(CONF_VERSION)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_version_text_sensor(sens)) diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp new file mode 100644 index 00000000000..765a80ae6f3 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp @@ -0,0 +1,49 @@ +#include "hoermann_hcp_text_sensor.h" + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.text_sensor"; + +// Indexed by DoorState. Each fits the 15 characters std::string keeps inline, so publishing never allocates. +PROGMEM_STRING_TABLE(DoorStateStrings, "Open", "Opening", "Closed", "Closing", "Half open", "Moving to vent", + "Vent position", "Moving to half", "Stopped"); +static_assert(DoorStateStrings::COUNT == static_cast(DoorState::STOPPED) + 1, + "DoorStateStrings must list every DoorState in order"); + +void HoermannHcpDoorStateTextSensor::setup() { + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); + // A state decoded before the callback was added would otherwise wait for the next change. + this->update_from_state_(); +} + +void HoermannHcpDoorStateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Hoermann HCP Door State", this); } + +void HoermannHcpDoorStateTextSensor::update_from_state_() { + // The last state stays while the bus controller is gone, and is published again once it is back. + if (!this->parent_->is_valid()) { + this->published_ = false; + return; + } + if (!this->parent_->is_door_state_known()) + return; + // Any hub change runs this, so only a new door state is published. + const DoorState state = this->parent_->get_door_state(); + if (this->published_ && state == this->published_state_) + return; + this->published_ = true; + this->published_state_ = state; + char text[16]; + ESPHOME_strncpy_P(text, + reinterpret_cast( + DoorStateStrings::get_progmem_str(static_cast(state), DoorStateStrings::LAST_INDEX)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h new file mode 100644 index 00000000000..a3fb91cd9a8 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpDoorStateTextSensor : public text_sensor::TextSensor, public Component { + public: + explicit HoermannHcpDoorStateTextSensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + void update_from_state_(); + + HoermannHcp *const parent_; + DoorState published_state_{DoorState::CLOSED}; + bool published_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index bd074ab6b58..16e4c81d344 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -1,3 +1,5 @@ +import sys + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -8,9 +10,9 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_HOST, ThreadModel, + Toolchain, ) -from esphome.core import CORE -from esphome.platformio.toolchain import copy_ccache_script +from esphome.core import CORE, EsphomeError from esphome.types import ConfigType from .const import KEY_HOST @@ -37,7 +39,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, } ), - cv.require_platformio_toolchain("host"), + # The host builds with the machine's own compiler through ninja; there + # is no PlatformIO backend, so a CLI --toolchain must name this one + cv.resolve_toolchain("host", (Toolchain.HOST,), Toolchain.HOST), set_core_data, ) @@ -48,16 +52,23 @@ async def to_code(config: ConfigType) -> None: # The prefs file finds stored preferences by key, so key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) - cg.add_build_flag("-std=gnu++20") + cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", "host") cg.add_define("ESPHOME_VARIANT", "HOST") cg.add_define(ThreadModel.MULTI_ATOMICS) - cg.add_platformio_option("platform", "platformio/native") - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) -# Called by writer.py -def copy_files() -> None: - copy_ccache_script() +# Called by __main__.compile_program; True means this platform built the +# program itself instead of falling through to the PlatformIO toolchain. +def run_compile(args: object, config: ConfigType) -> bool: + from esphome.host import toolchain + + if sys.platform == "win32": + # Here, not in validation: `esphome config` must keep working + raise EsphomeError( + "The host platform uses POSIX interfaces and does not build on " + "Windows; use WSL or a Linux or macOS machine" + ) + if toolchain.run_compile(config, CORE.verbose) != 0: + raise EsphomeError("Host build failed") + return True diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 4471dffdc2b..71668b8556f 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -331,27 +331,46 @@ class HttpRequestComponent : public Component { void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } - std::shared_ptr get(const std::string &url) { - return this->start(url, "GET", "", std::vector
{}); - } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + std::shared_ptr get(const char *url) { return this->start(url, "GET", "", std::vector
{}); } + std::shared_ptr get(const char *url, const std::vector
&request_headers) { return this->start(url, "GET", "", request_headers); } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + std::shared_ptr get(const char *url, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->start(url, "GET", "", request_headers, lower_case_collect_headers); } - std::shared_ptr post(const std::string &url, const std::string &body) { + std::shared_ptr post(const char *url, const std::string &body) { return this->start(url, "POST", body, std::vector
{}); } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers) { + return this->start(url, "POST", body, request_headers); + } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + } + + std::shared_ptr get(const std::string &url) { return this->get(url.c_str()); } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + return this->get(url.c_str(), request_headers); + } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->get(url.c_str(), request_headers, lower_case_collect_headers); + } + std::shared_ptr post(const std::string &url, const std::string &body) { + return this->post(url.c_str(), body); + } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers) { - return this->start(url, "POST", body, request_headers); + return this->post(url.c_str(), body, request_headers); } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + return this->post(url.c_str(), body, request_headers, lower_case_collect_headers); } // Remove before 2027.1.0 @@ -379,11 +398,15 @@ class HttpRequestComponent : public Component { return this->post(url, body, std::vector
(request_headers.begin(), request_headers.end()), collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads return this->perform(url, method, body, request_headers, {}); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers); + } // Remove before 2027.1.0 ESPDEPRECATED("Pass request_headers as std::vector
instead of std::list. Removed in 2027.1.0.", "2026.7.0") @@ -403,7 +426,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); } // Remove before 2027.1.0 @@ -418,7 +441,8 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower); } // Remove before 2027.1.0 @@ -426,19 +450,25 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower_case_collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->perform(url, method, body, request_headers, lower_case_collect_headers); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers); + } protected: - virtual std::shared_ptr perform(const std::string &url, const std::string &method, - const std::string &body, const std::vector
&request_headers, + virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, const std::vector &lower_case_collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; @@ -499,8 +529,8 @@ template class HttpRequestSendAction final : public Actionparent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, - this->lower_case_collect_headers_); + auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, + request_headers, this->lower_case_collect_headers_); auto captured_args = std::make_tuple(x...); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 43ab2e5b53a..0d968222e9e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -2,6 +2,8 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -22,8 +24,7 @@ static const char *const TAG = "http_request"; static constexpr int ESP8266_SSL_ERR_OOM = -1000; #endif -std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -37,7 +38,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur const uint32_t start = millis(); - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); @@ -70,19 +71,19 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur stream_ptr = std::make_unique(); #endif // USE_HTTP_REQUEST_ESP8266_HTTPS - bool status = container->client_.begin(*stream_ptr, url.c_str()); + bool status = container->client_.begin(*stream_ptr, url); #elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } - bool status = container->client_.begin(url.c_str()); + bool status = container->client_.begin(url); #endif App.feed_wdt(); if (!status) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.collectHeaders(header_keys, index); App.feed_wdt(); - container->status_code = container->client_.sendRequest(method.c_str(), body.c_str()); + container->status_code = container->client_.sendRequest(method, body.c_str()); App.feed_wdt(); if (container->status_code < 0) { #if defined(USE_ESP8266) && defined(USE_HTTP_REQUEST_ESP8266_HTTPS) @@ -139,7 +140,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur } #endif - ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url.c_str(), + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); this->status_momentary_error("failed", 1000); @@ -147,7 +148,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur return nullptr; } if (!is_success(container->status_code)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 028b9f44a1c..62737f4d0d1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -54,7 +54,7 @@ class HttpRequestArduino final : public HttpRequestComponent { #endif protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; #ifdef USE_ESP8266 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index cf231e20bdc..a7889702023 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -5,6 +5,8 @@ #include "httplib.h" #include "http_request_host.h" +#include + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -16,8 +18,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request"; -std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -27,10 +28,10 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } std::regex url_regex(R"(^(([^:\/?#]+):)?(//([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)", std::regex::extended); - std::smatch url_match_result; + std::cmatch url_match_result; if (!std::regex_match(url, url_match_result, url_regex) || url_match_result.length() < 7) { - ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url); return nullptr; } auto host = url_match_result[4].str(); @@ -54,7 +55,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } httplib::Client client(scheme_host.c_str()); if (!client.is_valid()) { - ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; } client.set_follow_location(this->follow_redirects_); @@ -64,41 +65,41 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, #endif httplib::Result result; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { result = client.Get(path, h_headers, [&](const char *data, size_t data_length) { ESP_LOGV(TAG, "Got data length: %zu", data_length); container->response_body_.insert(container->response_body_.end(), (const uint8_t *) data, (const uint8_t *) data + data_length); return true; }); - } else if (method == "HEAD") { + } else if (strcmp(method, "HEAD") == 0) { result = client.Head(path, h_headers); - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { result = client.Put(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { result = client.Patch(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { result = client.Post(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } } else { - ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method.c_str(), url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method, url); container->end(); return nullptr; } App.feed_wdt(); if (!result) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url.c_str(), (unsigned) result.error()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, auto response = *result; container->status_code = response.status; if (!is_success(response.status)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), response.status); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 9045702f46a..0ae9f2e27b9 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -18,7 +18,7 @@ class HttpContainerHost : public HttpContainer { class HttpRequestHost final : public HttpRequestComponent { public: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 10313be89db..4e5a2c42b57 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -48,8 +50,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { return ESP_OK; } -std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -59,15 +60,15 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } esp_http_client_method_t method_idf; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { method_idf = HTTP_METHOD_GET; - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { method_idf = HTTP_METHOD_POST; - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { method_idf = HTTP_METHOD_PUT; - } else if (method == "DELETE") { + } else if (strcmp(method, "DELETE") == 0) { method_idf = HTTP_METHOD_DELETE; - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { this->status_momentary_error("failed", ERROR_DURATION_MS); @@ -75,11 +76,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c return nullptr; } - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; esp_http_client_config_t config = {}; - config.url = url.c_str(); + config.url = url; config.method = method_idf; config.timeout_ms = this->timeout_; config.disable_auto_redirect = !this->follow_redirects_; @@ -218,7 +219,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } } - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 16a5b6a161c..f84dc9576bd 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -30,6 +30,9 @@ class HttpContainerIDF : public HttpContainer { class HttpRequestIDF final : public HttpRequestComponent { public: + // User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero. + HttpRequestIDF() {} + void dump_config() override; void set_buffer_size_rx(uint16_t buffer_size_rx) { this->buffer_size_rx_ = buffer_size_rx; } @@ -38,7 +41,7 @@ class HttpRequestIDF final : public HttpRequestComponent { void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; } protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 57dc86d55cf..6a74c00e8e5 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -1,5 +1,7 @@ #include "http_request_update.h" +#include + #include "esphome/core/application.h" #include "esphome/core/version.h" @@ -94,7 +96,7 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); if (container != nullptr) container->end(); result->error_str = LOG_STR("Failed to fetch manifest"); @@ -174,21 +176,26 @@ void HttpRequestUpdate::update_task(void *params) { allocator.deallocate(data, content_length); if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); result->error_str = LOG_STR("Failed to parse manifest JSON"); goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } // Merge source_url_ and firmware_url if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { - std::string path = info->firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - info->firmware_url = domain + path; + const char *source = this_update->source_url_; + const size_t source_len = strlen(source); + size_t prefix_len; + if (info->firmware_url[0] == '/') { + // scheme and host, up to the first slash after "https://" + const char *host_end = source_len > 8 ? strchr(source + 8, '/') : nullptr; + prefix_len = host_end != nullptr ? host_end - source : source_len; } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - info->firmware_url = domain + path; + // directory of the manifest, up to and including its last slash + const char *dir_end = strrchr(source, '/'); + prefix_len = dir_end != nullptr ? dir_end - source + 1 : 0; } + info->firmware_url.insert(0, source, prefix_len); } #ifdef ESPHOME_PROJECT_VERSION diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index be9fbf72bfd..05a741b6cd9 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -21,7 +21,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo void perform(bool force) override; void check() override { this->update(); } - void set_source_url(const std::string &source_url) { this->source_url_ = source_url; } + void set_source_url(const char *source_url) { this->source_url_ = source_url; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } @@ -33,13 +33,15 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; - std::string source_url_; static void update_task(void *params); #ifdef USE_ESP32 TaskHandle_t update_task_handle_{nullptr}; #endif uint8_t initial_check_remaining_{0}; + + private: + const char *source_url_{nullptr}; // literal from codegen }; } // namespace esphome::http_request diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index f86d62c5e8d..c4babfd797d 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -3,7 +3,6 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/core/automation.h" namespace esphome::htu21d { @@ -34,26 +33,4 @@ class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level) - - void play(const Ts &...x) override { - auto level = this->level_.value(x...); - - this->parent_->set_heater_level(level); - } -}; - -template class SetHeaterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, status) - - void play(const Ts &...x) override { - auto status = this->status_.value(x...); - - this->parent_->set_heater(status); - } -}; - } // namespace esphome::htu21d diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 86dca777255..d78267964b0 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,8 +17,6 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -27,8 +25,6 @@ htu21d_ns = cg.esphome_ns.namespace("htu21d") HTU21DComponent = htu21d_ns.class_( "HTU21DComponent", cg.PollingComponent, i2c.I2CDevice ) -SetHeaterLevelAction = htu21d_ns.class_("SetHeaterLevelAction", automation.Action) -SetHeaterAction = htu21d_ns.class_("SetHeaterAction", automation.Action) HTU21DSensorModels = htu21d_ns.enum("HTU21DSensorModels") MODELS = { @@ -71,24 +67,16 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - - if CONF_HEATER in config: - sens = await sensor.new_sensor(config[CONF_HEATER]) - cg.add(var.set_heater(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_HEATER, var.set_heater) cg.add(var.set_sensor_model(config[CONF_MODEL])) -@automation.register_action( +automation.register_apply_action( "htu21d.set_heater_level", - SetHeaterLevelAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HTU21DComponent), @@ -96,24 +84,11 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_LEVEL, ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "set_heater_level", cg.uint8), ) -async def set_heater_level_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]) - level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) - cg.add(var.set_level(level_)) - return var - -@automation.register_action( +automation.register_apply_action( "htu21d.set_heater", - SetHeaterAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HTU21DComponent), @@ -121,16 +96,5 @@ async def set_heater_level_to_code( }, key=CONF_STATUS, ), - synchronous=True, + automation.ApplyField(CONF_STATUS, "set_heater", cg.bool_), ) -async def set_heater_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]) - status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) - cg.add(var.set_status(status_)) - return var diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 3522acf049a..08d068d0f03 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -18,8 +18,7 @@ from esphome.const import ( CONF_ROTATION, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, EnumValue -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import EnumValue import esphome.final_validate as fv from esphome.helpers import add_class_to_obj from esphome.types import ConfigType @@ -167,7 +166,6 @@ ROTATIONS = { HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") -SetBrightnessAction = hub75_ns.class_("SetBrightnessAction", automation.Action) def _merge_board_pins(config: ConfigType) -> ConfigType: @@ -619,9 +617,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -@automation.register_action( +automation.register_apply_action( "hub75.set_brightness", - SetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), @@ -629,16 +626,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.uint8), ) -async def hub75_set_brightness_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]) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) - cg.add(var.set_brightness(template_)) - return var diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index ba652d427d9..d36928a83af 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,5 +1,4 @@ #include "hub75_component.h" -#include "esphome/core/application.h" #include @@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] return; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; driver_->set_pixel(x, y, color.r, color.g, color.b); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 98bc2e52e6f..dbadc56c9df 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -5,7 +5,6 @@ #include #include "esphome/components/display/display_buffer.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -51,13 +50,6 @@ class HUB75Display final : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, brightness) - - void play(const Ts &...x) override { this->parent_->set_brightness(this->brightness_.value(x...)); } -}; - } // namespace esphome::hub75 #endif diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ed5145d4b0e..ec4e459be78 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - if (err != ESP_OK) { + if (err != ESP_OK || bytes_written != size) { ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_FAIL; } - return err; + return ESP_OK; } void I2SAudioSpeakerSPDIF::setup() { @@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } - if (!successful_setup) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. - // This ensures the first data transmitted is valid SPDIF (not raw zeros from - // auto_clear) and prevents phantom DMA events before real audio is available. - // Each preloaded block pushes a 0-real-frame record so that the corresponding - // on_sent events drain in lockstep without crediting any audio frames. + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. Runs with + // the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { + bool ok = true; this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); - if (preload_err != ESP_OK) { - break; // DMA preload buffer full or error - } const uint32_t silence_record = 0; - xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) || + (xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) { + ok = false; + break; + } } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop + return ok; + }; - // Now register the callback and enable the channel + if (successful_setup) { + successful_setup = preload_silence(); + } + + if (successful_setup) { + // Register the callback before enabling so the first transmitted block generates a queued event. xQueueReset(this->i2s_event_queue_); const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK; + } + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard @@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; uint32_t spdif_dma_event_count = 0; + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data // to keep the receiver synced. Exits only via break (stream info change, silence timeout, - // lockstep desync, dropped event, or partial-write failure). + // or a failed lockstep resync). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion - // event; that desyncs the lockstep queues permanently and the only safe recovery is a full - // task restart. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); - break; - } // User-initiated stop. In SPDIF continuous mode, transition to silence output rather // than tearing the task down. this->spdif_silence_start_ = millis(); @@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place. Frames held back by decimation are credited too, since their + // blocks are discarded with the rest of the DMA contents. + this->spdif_encoder_->reset(); + const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames; + const bool resynced = this->resync_lockstep_(credited_frames, preload_silence); + unrecorded_frames = 0; + spdif_pending_frames = 0; + spdif_dma_event_count = 0; + resync_needed = false; + if (credited_frames > 0) { + // Real audio was dropped, so the silence timer's start no longer reflects the stream + this->spdif_silence_start_ = 0; + } + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; bool lockstep_broken = false; @@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // order matches DMA completion order. Empty records queue here means lockstep broke. uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } if (lockstep_broken) { - ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); - break; + resync_needed = true; + continue; } // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write @@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; - // resuming would credit the next iteration's bytes against an old block. Bail and - // let loop() restart the task with a clean encoder. - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // resuming would credit the next iteration's bytes against an old block. + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); partial_write_failure = true; break; } @@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } if (!block_committed) { @@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // or emit a full silence block if the encoder is empty. esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); if (err != ESP_OK) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } } // One block committed to DMA; push exactly one record carrying its real-audio frame count. // Failure here means the records queue is full, which violates the lockstep invariant. if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } // Silence-timeout tracking and graceful-stop reset. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 5e271e671e5..cb82b09f33a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -14,17 +14,19 @@ #include "esp_timer.h" -// esp-audio-libs -#include +#include namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. -// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +// Software volume control maps the user-facing (0.0, 1.0) range linearly to a dB reduction in +// [-49.0, 0.0] dB; 0.0 is silence. static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; +// Rate at which the software gain moves toward a new target. +static constexpr uint32_t GAIN_RAMP_MS_PER_DB = 1; + void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); @@ -34,9 +36,10 @@ void I2SAudioSpeakerBase::setup() { return; } - // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // Initialize volume control. When audio_dac is configured, this sets the DAC volume and mute state. // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); + this->set_mute_state(this->mute_state_); } void I2SAudioSpeakerBase::dump_config() { @@ -53,6 +56,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -70,17 +80,6 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); - // Lockstep-breaking error bits are latched by the task and cleared along with all other bits - // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { - ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { - ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); - } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -91,7 +90,14 @@ void I2SAudioSpeakerBase::loop() { this->speaker_task_handle_ = nullptr; this->stop_i2s_driver_(); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } this->status_clear_error(); this->on_task_stopped(); @@ -111,21 +117,28 @@ void I2SAudioSpeakerBase::loop() { break; } + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error("driver-failure", 1000); break; } - if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, - &this->speaker_task_handle_); + // Seed the ramp at the live target so this run adopts it instantly rather than fading to it + // from wherever the previous run left off. Posted here, not in the task: the ramp's mailbox + // allows one writer, and that is the main loop. + this->post_software_gain_(0); + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); - if (this->speaker_task_handle_ == nullptr) { - ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt - } + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough @@ -136,50 +149,31 @@ void I2SAudioSpeakerBase::loop() { } void I2SAudioSpeakerBase::set_volume(float volume) { - this->volume_ = volume; -#ifdef USE_AUDIO_DAC - if (this->audio_dac_ != nullptr) { - if (volume > 0.0f) { - this->audio_dac_->set_mute_off(); - } - this->audio_dac_->set_volume(volume); - } else -#endif // USE_AUDIO_DAC - { - // Fallback to software volume control by using a Q31 fixed point scaling factor. - // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely - // and avoid any floating-point precision issues that could cause slight volume reduction. - if (volume >= 1.0f) { - this->q31_volume_factor_ = INT32_MAX; - } else if (volume <= 0.0f) { - this->q31_volume_factor_ = 0; - } else { - this->q31_volume_factor_ = - esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); - } - } + speaker::Speaker::set_volume(volume); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); } void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { - this->mute_state_ = mute_state; + speaker::Speaker::set_mute_state(mute_state); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); +} + +void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } else -#endif // USE_AUDIO_DAC - { - if (mute_state) { - // Fallback to software volume control and scale by 0 - this->q31_volume_factor_ = 0; - } else { - // Revert to previous volume when unmuting - this->set_volume(this->volume_); - } + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp stays at unity } +#endif // USE_AUDIO_DAC + // Software volume control. The ramp treats 0 dB as unity and skips processing there. + float target_db; + if (this->is_silent_()) { + target_db = -INFINITY; + } else if (this->volume_ >= 1.0f) { + target_db = 0.0f; + } else { + target_db = remap(this->volume_, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f); + } + this->gain_ramp_.set_target_db_at_rate(target_db, rate_samples); } size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { @@ -211,8 +205,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t } bool I2SAudioSpeakerBase::has_buffered_data() const { - if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { return temp_ring_buffer->available() > 0; } return false; @@ -229,8 +223,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -239,11 +231,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { @@ -323,16 +314,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` - // and any per-buffer record queue maintained by the task are permanently desynced, so the task - // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP - // (so the task bails immediately, closing the race where loop() could clear the error bit - // before the task observes it). + // Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them. int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, - SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, - &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); @@ -340,15 +325,33 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } -void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { - if (this->q31_volume_factor_ == INT32_MAX) { - return; // Max volume, no processing needed +void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) { + // Stop DMA so no more completion events arrive while the queues are rebuilt + i2s_channel_disable(this->tx_handle_); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT); + + uint32_t frames = extra_frames; + uint32_t record_frames = 0; + while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) { + frames += record_frames; } + xQueueReset(this->i2s_event_queue_); + if (frames > 0) { + ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames); + this->audio_output_callback_(frames, esp_timer_get_time()); + } +} + +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp is never targeted + } +#endif // USE_AUDIO_DAC const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); + this->gain_ramp_.process(data, static_cast(bytes_per_sample), + this->current_stream_info_.bytes_to_samples(bytes_read)); } void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index adb6ca5e3f7..b443166ea1b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,6 +16,8 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include // esp-audio-libs + namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations @@ -34,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested - ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -77,19 +77,23 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool has_buffered_data() const override; - /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to convert the volume to a dB target for + /// the gain ramp. /// @param volume between 0.0 and 1.0 void set_volume(float volume) override; - /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Mutes or unmutes the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to post the mute state to the gain ramp. /// @param mute_state true for muting, false for unmuting void set_mute_state(bool mute_state) override; protected: + /// @brief Posts the ramp target derived from the current volume and mute state. No-op when an audio dac owns + /// volume. Main loop only. + /// @param rate_samples Samples the ramp takes per dB of change; 0 adopts the target at once + void post_software_gain_(uint32_t rate_samples); + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); @@ -128,7 +132,23 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} - /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as + /// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker + /// task only. + /// @param extra_frames Real frames the caller consumed that never reached a write record + /// @param preload Callable returning true once every DMA descriptor holds silence with a matching record + /// @return false if the preload or the channel enable failed; the caller should restart the task + template bool resync_lockstep_(uint32_t extra_frames, F &&preload) { + this->drain_lockstep_(extra_frames); + return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK); + } + + /// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight, + /// and empties both lockstep queues. + void drain_lockstep_(uint32_t extra_frames); + + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the + /// speaker task only. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); @@ -155,7 +175,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool pause_state_{false}; - int32_t q31_volume_factor_{INT32_MAX}; + // Smooths software gain changes. The main loop posts targets, the speaker task processes; + // GainRamp's mailbox makes that safe. The main loop is the only poster. + esp_audio_libs::gain::GainRamp gain_ramp_; audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 17c93763d63..b4b6173458b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() { } } - if (successful_setup) { - // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. - // This guarantees that every on_sent event has a corresponding write record from the start, so - // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every + // on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { size_t bytes_loaded = 0; esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, (unsigned) bytes_loaded); - successful_setup = false; - break; + return false; } uint32_t zero_real_frames = 0; if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. ESP_LOGV(TAG, "Failed to push preload write record"); - successful_setup = false; - break; + return false; } } + return true; + }; + + if (successful_setup) { + successful_setup = preload_silence(); } if (successful_setup) { @@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() { // stop to wait until every real-audio buffer has been confirmed played by an ISR event. uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion - // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout + const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence); + unrecorded_frames = 0; + pending_real_buffers = 0; + resync_needed = false; + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ // entry by construction (preloaded records at startup, plus exactly one record pushed per // iteration alongside exactly one DMA-buffer-sized write). @@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { // Should never happen: would indicate the lockstep invariant is broken. - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { - break; + resync_needed = true; + continue; } // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring @@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() { size_t bw = 0; i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != output_bytes) { - // A short real-audio write breaks DMA descriptor alignment for every subsequent event; - // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather + // than retry it: it was already narrowed in place. + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) output_bytes); + audio_source->consume(input_bytes); + real_frames_total += frames_to_write; partial_write_failure = true; break; } @@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; @@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != silence_bytes) { // Same descriptor-alignment hazard as a partial real-audio write. - ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) silence_bytes); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } } // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep - // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + // invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it. if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { - ESP_LOGV(TAG, "Exiting: write records queue full"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } if (real_frames_total > 0) { pending_real_buffers++; diff --git a/esphome/components/icnt86/__init__.py b/esphome/components/icnt86/__init__.py new file mode 100644 index 00000000000..07f3b4e31ca --- /dev/null +++ b/esphome/components/icnt86/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@danepowell"] diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp new file mode 100644 index 00000000000..62a4586ebc3 --- /dev/null +++ b/esphome/components/icnt86/icnt86.cpp @@ -0,0 +1,84 @@ +#include "icnt86.h" +#include "esphome/core/log.h" + +namespace esphome::icnt86 { + +static const char *const TAG = "icnt86"; +static constexpr uint16_t REG_TOUCH_NUM = 0x1001; +static constexpr uint16_t REG_POINT1 = 0x1002; +static constexpr uint8_t MAX_TOUCHES = 5; +static constexpr uint8_t POINT_SIZE = 7; + +void ICNT86Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen..."); + + // Register interrupt pin + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Perform reset if necessary + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } +} + +void ICNT86Touchscreen::update_touches() { + uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0}; + uint8_t mask[1] = {0x00}; + + if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch count"); + return; + } + uint8_t touch_count = buf[0]; + + if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch + this->status_clear_warning(); + return; + } + if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch points"); + return; + } + this->write_register16(REG_TOUCH_NUM, mask, 1); + ESP_LOGV(TAG, "Touch count: %d", touch_count); + this->status_clear_warning(); + + for (uint8_t i = 0; i < touch_count; i++) { + uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i]; + uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i]; + uint8_t pressure = buf[5 + 7 * i]; + uint8_t touch_id = buf[6 + 7 * i]; + + // A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_ + // false (when no other point is active) so send_touches_() reports the release as normal. + if (pressure != 0) { + this->add_raw_touch_position_(touch_id, x, y, pressure); + } + } +} + +void ICNT86Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/icnt86.h b/esphome/components/icnt86/icnt86.h new file mode 100644 index 00000000000..0d96b015247 --- /dev/null +++ b/esphome/components/icnt86/icnt86.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::icnt86 { + +class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{nullptr}; +}; + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py new file mode 100644 index 00000000000..5d7a7386120 --- /dev/null +++ b/esphome/components/icnt86/touchscreen.py @@ -0,0 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +CODEOWNERS = ["@danepowell"] +DEPENDENCIES = ["i2c"] + +icnt86_ns = cg.esphome_ns.namespace("icnt86") +ICNT86Touchscreen = icnt86_ns.class_( + "ICNT86Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ICNT86Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ).extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + cg.add( + var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) + ) + + if reset_pin_config := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 9b603683abc..bfe311be284 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color continue; // skip drawing } break; - case TRANSPARENCY_ALPHA_CHANNEL: { - auto on = (float) gray / 255.0f; - auto off = 1.0f - on; - // blend color_on and color_off - color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off, - color_on.b * on + color_off.b * off, 0xFF); + case TRANSPARENCY_ALPHA_CHANNEL: + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); break; - } default: break; } diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index ccc2f23f200..fd9e92c21d3 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -54,7 +54,6 @@ class Image : public display::BaseImage { const uint8_t *data_start_; Transparency transparency_; size_t bpp_{}; - size_t stride_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 412d143a486..9b57b6561f9 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -38,9 +38,11 @@ def _process_next_url(url: str) -> str: return url -async def setup_improv_core(var: MockObj, config: ConfigType, component: str) -> None: +async def setup_improv_core(var: MockObj, config: ConfigType) -> None: if next_url := config.get(CONF_NEXT_URL): cg.add(var.set_next_url(_process_next_url(next_url))) - cg.add_define(f"USE_{component.upper()}_NEXT_URL") + # One define for all transports: next_url_ is per object, so a transport + # configured without next_url: calls add_next_url_ and appends nothing. + cg.add_define("USE_IMPROV_NEXT_URL") cg.add_library("improv/Improv", "1.2.7") diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 1babeb5b5a2..6745f8064b1 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -8,7 +8,7 @@ namespace esphome::improv_base { -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL static const char *const TAG = "improv_base"; static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index 352bb75d5fc..97801302d4f 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL #include #endif @@ -11,12 +11,12 @@ namespace esphome::improv_base { class ImprovBase { public: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL void set_next_url(const char *next_url) { this->next_url_ = next_url; } #endif protected: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL /// Format next_url_ into buffer, replacing placeholders. Returns length written. size_t get_formatted_next_url_(char *buffer, size_t buffer_size); /// Append the formatted next_url to the RPC response, warning if it does not fit. diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/improv_ble/__init__.py similarity index 63% rename from esphome/components/esp32_improv/__init__.py rename to esphome/components/improv_ble/__init__.py index 32eb1660142..72ac5866286 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,14 +1,41 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, improv_base, output -from esphome.components.esp32_ble import BTLoggers +from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.const import ( + CONF_ID, + CONF_ON_START, + CONF_ON_STATE, + CONF_TRIGGER_ID, + PLATFORM_ESP32, +) +from esphome.core import CORE from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_server", "improv_base"] +# The BLE GATT server component that hosts the Improv service, per target +# platform. improv_ble itself is platform neutral; supporting another chip +# means adding its BLE server component here and the matching backend in +# improv_ble_component.cpp. Doubles as the platform gate below, so an +# unsupported chip is rejected in validation rather than at link time. +BLE_SERVER_BACKENDS: dict[str, str] = { + PLATFORM_ESP32: "esp32_ble_server", +} + + +def AUTO_LOAD() -> list[str]: + auto_load = ["improv_base"] + if backend := BLE_SERVER_BACKENDS.get(CORE.target_platform): + auto_load.append(backend) + return auto_load + + CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["wifi", "esp32"] +DEPENDENCIES = ["wifi"] + +# Legacy top-level YAML key that routes here; esphome/loader.py and +# esphome/config.py handle the warning and the key rename. +ALIASES = ["esp32_improv"] +ALIAS_REMOVAL_VERSION = "2027.4.0" CONF_AUTHORIZED_DURATION = "authorized_duration" CONF_AUTHORIZER = "authorizer" @@ -29,29 +56,29 @@ improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") State = improv_ns.enum("State") -esp32_improv_ns = cg.esphome_ns.namespace("esp32_improv") -ESP32ImprovComponent = esp32_improv_ns.class_("ESP32ImprovComponent", cg.Component) -ESP32ImprovProvisionedTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisionedTrigger", automation.Trigger.template() +improv_ble_ns = cg.esphome_ns.namespace("improv_ble") +ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) +ImprovBLEProvisionedTrigger = improv_ble_ns.class_( + "ImprovBLEProvisionedTrigger", automation.Trigger.template() ) -ESP32ImprovProvisioningTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisioningTrigger", automation.Trigger.template() +ImprovBLEProvisioningTrigger = improv_ble_ns.class_( + "ImprovBLEProvisioningTrigger", automation.Trigger.template() ) -ESP32ImprovStartTrigger = esp32_improv_ns.class_( - "ESP32ImprovStartTrigger", automation.Trigger.template() +ImprovBLEStartTrigger = improv_ble_ns.class_( + "ImprovBLEStartTrigger", automation.Trigger.template() ) -ESP32ImprovStateTrigger = esp32_improv_ns.class_( - "ESP32ImprovStateTrigger", automation.Trigger.template() +ImprovBLEStateTrigger = improv_ble_ns.class_( + "ImprovBLEStateTrigger", automation.Trigger.template() ) -ESP32ImprovStoppedTrigger = esp32_improv_ns.class_( - "ESP32ImprovStoppedTrigger", automation.Trigger.template() +ImprovBLEStoppedTrigger = improv_ble_ns.class_( + "ImprovBLEStoppedTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), + cv.GenerateID(): cv.declare_id(ImprovBLEComponent), cv.Required(CONF_AUTHORIZER): cv.Any( cv.none, cv.use_id(binary_sensor.BinarySensor) ), @@ -68,55 +95,60 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisionedTrigger + ImprovBLEProvisionedTrigger ), } ), cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisioningTrigger + ImprovBLEProvisioningTrigger ), } ), cv.Optional(CONF_ON_START): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStartTrigger + ImprovBLEStartTrigger ), } ), cv.Optional(CONF_ON_STATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStateTrigger + ImprovBLEStateTrigger ), } ), cv.Optional(CONF_ON_STOP): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStoppedTrigger + ImprovBLEStoppedTrigger ), } ), } ) .extend(improv_base.IMPROV_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on(list(BLE_SERVER_BACKENDS)), ) async def to_code(config: ConfigType) -> None: + # ESP32 backend setup: the platform gate above means this is the only backend + # that can reach to_code. Make it conditional when a second one is added. + from esphome.components import esp32_ble + # Register the loggers this component needs - esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + esp32_ble.register_bt_logger(esp32_ble.BTLoggers.GATT, esp32_ble.BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - cg.add_define("USE_IMPROV") + cg.add_define("USE_IMPROV_BLE") - await improv_base.setup_improv_core(var, config, "esp32_improv") + await improv_base.setup_improv_core(var, config) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) @@ -155,4 +187,4 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_ESP32_IMPROV_STATE_CALLBACK") + cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/improv_ble/automation.h similarity index 55% rename from esphome/components/esp32_improv/automation.h rename to esphome/components/improv_ble/automation.h index b3b61f47785..223a1292384 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/improv_ble/automation.h @@ -1,17 +1,17 @@ #pragma once #ifdef USE_ESP32 -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK -#include "esp32_improv_component.h" +#ifdef USE_IMPROV_BLE_STATE_CALLBACK +#include "improv_ble_component.h" #include "esphome/core/automation.h" #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { -class ESP32ImprovProvisionedTrigger final : public Trigger<> { +class ImprovBLEProvisionedTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { this->trigger(); @@ -20,12 +20,12 @@ class ESP32ImprovProvisionedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovProvisioningTrigger final : public Trigger<> { +class ImprovBLEProvisioningTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { this->trigger(); @@ -34,12 +34,12 @@ class ESP32ImprovProvisioningTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStartTrigger final : public Trigger<> { +class ImprovBLEStartTrigger final : public Trigger<> { public: - explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && !this->parent_->is_failed()) { @@ -49,12 +49,12 @@ class ESP32ImprovStartTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStateTrigger final : public Trigger { public: - explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (!this->parent_->is_failed()) { this->trigger(state, error); @@ -63,12 +63,12 @@ class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStoppedTrigger final : public Trigger<> { public: - explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { this->trigger(); @@ -77,10 +77,10 @@ class ESP32ImprovStoppedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp similarity index 91% rename from esphome/components/esp32_improv/esp32_improv_component.cpp rename to esphome/components/improv_ble/improv_ble_component.cpp index 9ec6eb7bab6..0a20beb33c8 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -1,10 +1,7 @@ -#include "esp32_improv_component.h" +#include "improv_ble_component.h" #include -#include "esphome/components/bytebuffer/bytebuffer.h" -#include "esphome/components/esp32_ble/ble.h" -#include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -15,11 +12,15 @@ #ifdef USE_ESP32 -namespace esphome::esp32_improv { +#include "esphome/components/bytebuffer/bytebuffer.h" +#include "esphome/components/esp32_ble/ble.h" +#include "esphome/components/esp32_ble_server/ble_2902.h" + +namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "esp32_improv.component"; +static const char *const TAG = "improv_ble.component"; static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -38,9 +39,9 @@ static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 -ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } +ImprovBLEComponent::ImprovBLEComponent() { global_improv_component = this; } -void ESP32ImprovComponent::setup() { +void ImprovBLEComponent::setup() { #ifdef USE_BINARY_SENSOR if (this->authorizer_ != nullptr) { this->authorizer_->add_on_state_callback([this](bool state) { @@ -66,7 +67,7 @@ void ESP32ImprovComponent::setup() { this->disable_loop(); } -void ESP32ImprovComponent::setup_characteristics() { +void ImprovBLEComponent::setup_characteristics() { this->status_ = this->service_->create_characteristic( improv::STATUS_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); BLEDescriptor *status_descriptor = new BLE2902(); @@ -104,11 +105,11 @@ void ESP32ImprovComponent::setup_characteristics() { this->setup_complete_ = true; } -void ESP32ImprovComponent::loop() { +void ImprovBLEComponent::loop() { if (!global_ble_server->is_running()) { if (this->state_ != improv::STATE_STOPPED) { this->state_ = improv::STATE_STOPPED; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } @@ -200,23 +201,19 @@ void ESP32ImprovComponent::loop() { } } -void ESP32ImprovComponent::set_status_indicator_state_(bool state) { +void ImprovBLEComponent::set_status_indicator_state_(bool state) { #ifdef USE_OUTPUT if (this->status_indicator_ == nullptr) return; if (this->status_indicator_state_ == state) return; this->status_indicator_state_ = state; - if (state) { - this->status_indicator_->turn_on(); - } else { - this->status_indicator_->turn_off(); - } + this->status_indicator_->set_state(state); #endif } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -const char *ESP32ImprovComponent::state_to_string_(improv::State state) { +const char *ImprovBLEComponent::state_to_string_(improv::State state) { switch (state) { case improv::STATE_STOPPED: return "STOPPED"; @@ -234,7 +231,7 @@ const char *ESP32ImprovComponent::state_to_string_(improv::State state) { } #endif -bool ESP32ImprovComponent::check_identify_() { +bool ImprovBLEComponent::check_identify_() { uint32_t now = millis(); bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_; @@ -246,7 +243,7 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { +void ImprovBLEComponent::set_state_(improv::State state, bool update_advertising) { // Skip if state hasn't changed if (this->state_ == state) { return; @@ -274,12 +271,12 @@ void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertisi // Advertise the new state via service data this->advertise_service_data_(); } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } -void ESP32ImprovComponent::set_error_(improv::Error error) { +void ImprovBLEComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } @@ -295,14 +292,14 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::span response) { +void ImprovBLEComponent::send_response_(std::span response) { // The BLE characteristic owns its value, so one exact-size copy is required here this->rpc_response_->set_value(std::vector(response.begin(), response.end())); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } -void ESP32ImprovComponent::start() { +void ImprovBLEComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; @@ -320,7 +317,7 @@ void ESP32ImprovComponent::start() { this->enable_loop(); } -void ESP32ImprovComponent::stop() { +void ImprovBLEComponent::stop() { this->should_start_ = false; // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing @@ -335,10 +332,10 @@ void ESP32ImprovComponent::stop() { }); } -float ESP32ImprovComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +float ImprovBLEComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } -void ESP32ImprovComponent::dump_config() { - ESP_LOGCONFIG(TAG, "ESP32 Improv:"); +void ImprovBLEComponent::dump_config() { + ESP_LOGCONFIG(TAG, "Improv BLE:"); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_); #endif @@ -347,7 +344,7 @@ void ESP32ImprovComponent::dump_config() { #endif } -void ESP32ImprovComponent::process_incoming_data_() { +void ImprovBLEComponent::process_incoming_data_() { if (this->incoming_data_.size() < 3) return; uint8_t length = this->incoming_data_[1]; @@ -422,7 +419,7 @@ void ESP32ImprovComponent::process_incoming_data_() { } } -void ESP32ImprovComponent::on_wifi_connect_timeout_() { +void ImprovBLEComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); #ifdef USE_BINARY_SENSOR @@ -433,7 +430,7 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } -void ESP32ImprovComponent::check_wifi_connection_() { +void ImprovBLEComponent::check_wifi_connection_() { if (!wifi::global_wifi_component->is_connected()) { return; } @@ -447,7 +444,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { std::array buf; improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS); -#ifdef USE_ESP32_IMPROV_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL // Add next_url if configured (should be first per Improv BLE spec) this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif @@ -480,7 +477,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->stop(); } -void ESP32ImprovComponent::advertise_service_data_() { +void ImprovBLEComponent::advertise_service_data_() { uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; service_data[0] = IMPROV_PROTOCOL_ID_1; // PR service_data[1] = IMPROV_PROTOCOL_ID_2; // IM @@ -499,7 +496,7 @@ void ESP32ImprovComponent::advertise_service_data_() { esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); } -void ESP32ImprovComponent::update_advertising_type_() { +void ImprovBLEComponent::update_advertising_type_() { uint32_t now = App.get_loop_component_start_time(); // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data @@ -524,21 +521,21 @@ void ESP32ImprovComponent::update_advertising_type_() { } } -void ESP32ImprovComponent::request_advertising_() { +void ImprovBLEComponent::request_advertising_() { if (this->advertising_requested_) return; this->advertising_requested_ = true; esp32_ble::global_ble->advertising_start(); } -void ESP32ImprovComponent::release_advertising_() { +void ImprovBLEComponent::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 ImprovBLEComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; @@ -548,8 +545,8 @@ improv::State ESP32ImprovComponent::get_initial_state_() const { #endif } -ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +ImprovBLEComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/improv_ble/improv_ble_component.h similarity index 86% rename from esphome/components/esp32_improv/esp32_improv_component.h rename to esphome/components/improv_ble/improv_ble_component.h index a40d60552a8..2552bed69b5 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -5,12 +5,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -#include "esphome/components/esp32_ble_server/ble_characteristic.h" -#include "esphome/components/esp32_ble_server/ble_server.h" #include "esphome/components/improv_base/improv_base.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK #include "esphome/core/automation.h" #endif @@ -25,17 +23,23 @@ #include #include +// ESP-IDF is currently the only target platform with a BLE GATT server, so it is +// the only backend this component has. The Python side keeps the platform table +// (BLE_SERVER_BACKENDS in __init__.py); a second backend adds another arm here. #ifdef USE_ESP32 +#include "esphome/components/esp32_ble_server/ble_characteristic.h" +#include "esphome/components/esp32_ble_server/ble_server.h" + #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { using namespace esp32_ble_server; -class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { +class ImprovBLEComponent final : public Component, public improv_base::ImprovBase { public: - ESP32ImprovComponent(); + ImprovBLEComponent(); void dump_config() override; void loop() override; void setup() override; @@ -47,7 +51,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool is_active() const { return this->state_ != improv::STATE_STOPPED; } bool should_start() const { return this->should_start_; } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK template void add_on_state_callback(F &&callback) { this->state_callback_.add(std::forward(callback)); } @@ -97,7 +101,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB improv::State state_{improv::STATE_STOPPED}; improv::Error error_state_{improv::ERROR_NONE}; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif @@ -125,8 +129,8 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern ESP32ImprovComponent *global_improv_component; +extern ImprovBLEComponent *global_improv_component; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index a34e2ab7931..0231791e9b3 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -70,7 +70,7 @@ FINAL_VALIDATE_SCHEMA = validate_transport async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await improv_base.setup_improv_core(var, config, "improv_serial") + await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: cg.add(var.set_uart(await cg.get_variable(uart_id))) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ffa7b79d9bf..3827fb6ed45 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -208,7 +208,7 @@ void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &buil void ImprovSerialComponent::send_settings_response_(improv::Command command) { std::array buf; improv::RpcResponseBuilder builder(buf, command); -#ifdef USE_IMPROV_SERIAL_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 68cdd752149..c7d89c76d6b 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -55,7 +55,7 @@ static const uint8_t IMPROV_SERIAL_VERSION = 1; #ifdef USE_WIFI // Wi-Fi connect failure timers: a fresh provision reports at 30 s (stock behavior), while // switching networks on an already-connected device (disconnect + reconnect) can legitimately -// take longer; 90 s matches esp32_improv's default wifi_timeout. +// take longer; 90 s matches improv_ble's default wifi_timeout. static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 30000; static const uint32_t WIFI_SWITCH_TIMEOUT_MS = 90000; #endif diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 97482f81a01..0a81adc785e 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -80,18 +80,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) cg.add(var.set_max_voltage_v(config[CONF_MAX_VOLTAGE])) - if CONF_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE]) - cg.add(var.set_bus_voltage_sensor(sens)) - - if CONF_SHUNT_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_SHUNT_VOLTAGE]) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 4fd98fbcd40..af1793450c9 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -133,18 +133,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_adc_avg_samples(config[CONF_ADC_AVERAGING])) - if CONF_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE]) - cg.add(var.set_bus_voltage_sensor(sens)) - - if CONF_SHUNT_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_SHUNT_VOLTAGE]) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7bb589f0b15..b6cf0e38f30 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, + UNIT_MILLIVOLT, UNIT_VOLT, UNIT_WATT, UNIT_WATT_HOURS, @@ -44,7 +45,6 @@ CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient" CONF_RESET_ON_BOOT = "reset_on_boot" UNIT_COULOMB = "C" UNIT_JOULE = "J" -UNIT_MILLIVOLT = "mV" ina2xx_base_ns = cg.esphome_ns.namespace("ina2xx_base") INA2XX = ina2xx_base_ns.class_("INA2XX", cg.PollingComponent) @@ -225,38 +225,13 @@ async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: cg.add(var.set_adc_time_shunt_voltage(adc_time_config)) cg.add(var.set_adc_time_die_temperature(adc_time_config)) - if conf := config.get(CONF_SHUNT_VOLTAGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if conf := config.get(CONF_BUS_VOLTAGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_bus_voltage_sensor(sens)) - - if conf := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_die_temperature_sensor(sens)) - - if conf := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - - if conf := config.get(CONF_POWER): - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - - if conf := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor_wh(sens)) - - if conf := config.get(CONF_ENERGY_JOULES): - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor_j(sens)) - - if conf := config.get(CONF_CHARGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_charge_sensor_ah(sens)) - - if conf := config.get(CONF_CHARGE_COULOMBS): - sens = await sensor.new_sensor(conf) - cg.add(var.set_charge_sensor_c(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_TEMPERATURE, var.set_die_temperature_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor_wh) + await sensors(CONF_ENERGY_JOULES, var.set_energy_sensor_j) + await sensors(CONF_CHARGE, var.set_charge_sensor_ah) + await sensors(CONF_CHARGE_COULOMBS, var.set_charge_sensor_c) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 5a909738c6f..83039a5a9bf 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -59,11 +59,6 @@ void Infrared::setup() { // Set up traits based on configuration this->traits_.set_supports_transmitter(this->has_transmitter()); this->traits_.set_supports_receiver(this->has_receiver()); - - // Register as listener for received IR data - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void Infrared::dump_config() { diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index b6863e37ce5..afbde57be2f 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -119,7 +119,8 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index 84a207020ea..f6524021c08 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -71,15 +71,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_EXTERNAL_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE]) - cg.add(var.set_external_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 019c3ee0740..22d664cd555 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -71,16 +71,4 @@ class IntegrationSensor final : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetValueAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { this->parent_->set_value(this->value_.value(x...)); } -}; - } // namespace esphome::integration diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 82e8ba8df8e..a68cb575192 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,17 +11,13 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) -from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( "IntegrationSensor", sensor.Sensor, cg.Component ) -ResetAction = integration_ns.class_("ResetAction", automation.Action) -SetValueAction = integration_ns.class_("SetValueAction", automation.Action) IntegrationSensorTime = integration_ns.enum("IntegrationSensorTime") INTEGRATION_TIMES = { @@ -106,46 +102,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_restore(config[CONF_RESTORE])) -@automation.register_action( +automation.register_apply_action( "sensor.integration.reset", - ResetAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) -async def sensor_integration_reset_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]) - return var - -@automation.register_action( +automation.register_apply_action( "sensor.integration.set_value", - SetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -async def sensor_integration_set_value_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]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 90831cf211d..6a9889ef29c 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -13,6 +13,9 @@ namespace esphome::internal_temperature { class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) InternalTemperatureSensor()` would zero-fill .bss that is already zero. + InternalTemperatureSensor() {} + #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; #endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 3218889721c..288bd916738 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,7 +3,12 @@ from typing import Any import esphome.codegen as cg -from esphome.components import infrared, remote_receiver, remote_transmitter +from esphome.components import ( + infrared, + remote_base, + remote_receiver, + remote_transmitter, +) from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -82,8 +87,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) # Set receiver demodulation frequency if specified (metadata only, no hardware effect) if CONF_RECEIVER_FREQUENCY in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index c13c6198cb6..ceb4c9a67c3 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -97,10 +97,6 @@ void RfProxy::setup() { // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); - - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void RfProxy::dump_config() { diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 5fc683354ba..1aa4394fe84 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -56,7 +56,8 @@ class RfProxy final : public radio_frequency::RadioFrequency { /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index a243909837f..28b8fd5953a 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,7 +1,12 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import radio_frequency, remote_receiver, remote_transmitter +from esphome.components import ( + radio_frequency, + remote_base, + remote_receiver, + remote_transmitter, +) import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -66,5 +71,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transmitter(transmitter)) if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 57bf86c4c6c..b5f3019a126 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -36,8 +36,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.cpp_generator import RawExpression from esphome.final_validate import full_config from esphome.types import ConfigType @@ -61,7 +60,6 @@ VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) it8951_ns = cg.esphome_ns.namespace("it8951") IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) -IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) # Hardware waveform modes exposed to YAML. Strings are mapped to the C++ # UpdateMode enum so the runtime can store the mode as a uint16_t rather @@ -80,6 +78,7 @@ UPDATE_MODE_OPTIONS = { "A2": UpdateMode.UPDATE_MODE_A2, "FAST": UpdateMode.UPDATE_MODE_DU, "FULL": UpdateMode.UPDATE_MODE_GC16, + "DEFAULT": UpdateMode.UPDATE_MODE_NONE, } # Maps the YAML mode string directly to the C++ UpdateMode enum value, so the # config option and the it8951.update action share one validator. @@ -419,26 +418,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transform(RawExpression(str(transform_value)))) -@automation.register_action( +automation.register_apply_action( "it8951.update", - IT8951UpdateAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IT8951Display), - cv.Optional(CONF_MODE): cv.templatable(update_mode), + cv.Optional(CONF_MODE, default="DEFAULT"): cv.templatable(update_mode), } ), - synchronous=True, + automation.ApplyField(CONF_MODE, "update_mode", UpdateMode), ) -async def it8951_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - display_var = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, display_var) - if mode := config.get(CONF_MODE): - mode = await cg.templatable(mode, args, UpdateMode) - cg.add(var.set_mode(mode)) - return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 179c2e5f63d..a6511b63c41 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -777,12 +777,12 @@ void IT8951Display::update() { } void IT8951Display::update_mode(UpdateMode mode) { - if (!this->is_ready()) - return; if (mode == UPDATE_MODE_NONE) { - ESP_LOGW(TAG, "Unknown update mode"); + this->update(); return; } + if (!this->is_ready()) + return; this->start_update_(mode); } @@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const { } bool IT8951Display::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; this->apply_transform_(x, y); if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) @@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) { void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { if (this->buffer_ == nullptr) return; - App.feed_wdt(); + this->feed_wdt_per_pixel_(); if (!this->rotate_coordinates_(x, y)) return; this->write_pixel_native_(static_cast(x), static_cast(y), color); diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index a5ed03e8c4b..fcdc6d1d196 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -6,7 +6,6 @@ #include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -350,24 +349,4 @@ class IT8951Display : public Display, uint8_t dev_info_attempts_{0}; }; -// --- Automation action --- -template class IT8951UpdateAction : public Action { - public: - explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} - TEMPLATABLE_VALUE(UpdateMode, mode) - - protected: - void play(const Ts &...x) override { - if (!this->display_->is_ready()) - return; - if (this->mode_.has_value()) { - this->display_->update_mode(this->mode_.value(x...)); - } else { - this->display_->update(); - } - } - - IT8951Display *display_; -}; - } // namespace esphome::it8951 diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index af7eb7e733d..618c56faa14 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -21,3 +21,9 @@ async def to_code(config: ConfigType) -> None: cg.add_library("bblanchon/ArduinoJson", "7.4.3") cg.add_define("USE_JSON") cg.add_global(json_ns.using) + + +def enable_arena() -> None: + """Compile JsonArena and the allocator constructor of JsonBuilder; only the consumers that build + documents in a stack arena pay for them.""" + cg.add_define("USE_JSON_ARENA") diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 984134b95f9..3ecfce8b699 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -1,4 +1,6 @@ #include "json_util.h" + +#include #include "esphome/core/log.h" // ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h @@ -44,11 +46,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { ESP_LOGE(TAG, "No data to parse"); return JsonObject(); // return unbound object } -#ifdef USE_PSRAM - JsonDocument json_document(&global_json_allocator); -#else - JsonDocument json_document; -#endif + JsonDocument json_document(heap_json_allocator()); if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return JsonObject(); // return unbound object @@ -66,6 +64,35 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } +JsonBuilder::JsonBuilder() : doc_(heap_json_allocator()) {} +#ifdef USE_JSON_ARENA +JsonBuilder::JsonBuilder(ArduinoJson::Allocator *allocator) : doc_(allocator) {} +#endif + +ArduinoJson::Allocator *heap_json_allocator() { +#ifdef USE_PSRAM + return &global_json_allocator; +#else + return ArduinoJson::detail::DefaultAllocator::instance(); +#endif +} + +size_t JsonBuilder::serialize_to(char *buf, size_t cap) { + if (doc_.overflowed()) { + ESP_LOGE(TAG, "JSON document overflow"); + // Same contract as serializeJson; written by hand so no "{}" literal lives in RAM on ESP8266 + size_t n = 0; + if (n < cap) + buf[n++] = '{'; + if (n < cap) + buf[n++] = '}'; + if (n < cap) + buf[n] = '\0'; + return n; + } + return serializeJson(doc_, buf, cap); +} + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING @@ -107,17 +134,7 @@ SerializationBuffer<> JsonBuilder::serialize() { constexpr size_t buf_size = SerializationBuffer<>::BUFFER_SIZE; SerializationBuffer<> result(buf_size - 1); // Max content size (reserve 1 for null) - if (doc_.overflowed()) { - ESP_LOGE(TAG, "JSON document overflow"); - auto *buf = result.data_writable_(); - buf[0] = '{'; - buf[1] = '}'; - buf[2] = '\0'; - result.set_size_(2); - return result; - } - - size_t size = serializeJson(doc_, result.data_writable_(), buf_size); + size_t size = this->serialize_to(result.data_writable_(), buf_size); if (size < buf_size) { // Fits in stack buffer - update size to actual length result.set_size_(size); @@ -132,7 +149,7 @@ SerializationBuffer<> JsonBuilder::serialize() { size_t heap_size = buf_size * 2; while (heap_size <= max_heap_size) { result.reallocate_heap_(heap_size - 1); - size = serializeJson(doc_, result.data_writable_(), heap_size); + size = this->serialize_to(result.data_writable_(), heap_size); if (size < heap_size) { result.set_size_(size); return result; diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b8..be12d0aa38a 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -165,9 +167,89 @@ inline JsonDocument parse_json(const std::string &data) { return parse_json(reinterpret_cast(data.c_str()), data.size()); } +/// The allocator a JsonBuilder uses by default (PSRAM first when available) +ArduinoJson::Allocator *heap_json_allocator(); + +#ifdef USE_JSON_ARENA +/// Size of one ArduinoJson slot pool, the first allocation every document makes (1 KB on 32 bit targets) +constexpr size_t JSON_POOL_BYTES = ARDUINOJSON_POOL_CAPACITY * sizeof(ArduinoJson::detail::VariantData); +/// One pool plus room for copied string nodes; a 40 option select with linked options fits +constexpr size_t JSON_ARENA_SIZE = JSON_POOL_BYTES + 1152; +static_assert(sizeof(void *) != 4 || JSON_ARENA_SIZE == 2176, "the arena was sized for a 1 KB pool"); + +/// Bump allocator over a fixed buffer for a document built and serialized in one scope. What the +/// buffer cannot hold goes to the heap allocator; nothing is freed until the arena goes away. +template class JsonArena final : public ArduinoJson::Allocator { + public: + // Takes what the buffer cannot hold + explicit JsonArena(ArduinoJson::Allocator *fallback = heap_json_allocator()) : fallback_(fallback) {} + // The document points into buf_ + JsonArena(const JsonArena &) = delete; + JsonArena &operator=(const JsonArena &) = delete; + + void *allocate(size_t size) override { + if (size > N) { + return this->fallback_->allocate(size); // also keeps the rounding below from wrapping + } + size = (size + ALIGN - 1) & ~(ALIGN - 1); + if (size > N - this->used_) { + return this->fallback_->allocate(size); + } + this->last_ = this->used_; + this->used_ += size; + return this->buf_ + this->last_; + } + void deallocate(void *ptr) override { + if (!this->owns_(ptr)) { + this->fallback_->deallocate(ptr); + } + } + void *reallocate(void *ptr, size_t new_size) override { + if (!this->owns_(ptr)) { + return this->fallback_->reallocate(ptr, new_size); + } + const size_t off = static_cast(ptr) - this->buf_; + const size_t size = new_size > N ? N + ALIGN : (new_size + ALIGN - 1) & ~(ALIGN - 1); + const bool newest = off == this->last_; + if (newest && size <= N - off) { + this->used_ = off + size; // the newest block grows or shrinks in place + return ptr; + } + // An older block's size is unknown; copying to the end of the buffer stays in bounds + const size_t old_size = newest ? this->used_ - off : N - off; + void *moved = this->fallback_->allocate(new_size); + if (moved == nullptr) { + return nullptr; // the caller keeps ptr, so its arena space stays reserved + } + std::memcpy(moved, ptr, std::min(new_size, old_size)); + if (newest) { + this->used_ = off; // it moved to the heap, so its arena space is free again + } + return moved; + } + /// Bytes of the buffer handed out so far + size_t used() const { return this->used_; } + + private: + static constexpr size_t ALIGN = alignof(std::max_align_t); + bool owns_(const void *ptr) const { return ptr >= this->buf_ && ptr < this->buf_ + N; } + ArduinoJson::Allocator *fallback_; + alignas(ALIGN) uint8_t buf_[N]; + size_t used_{0}; + size_t last_{0}; +}; +#endif // USE_JSON_ARENA + /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: + // Out of line: inlining the JsonDocument constructor duplicates it at every call site + JsonBuilder(); +#ifdef USE_JSON_ARENA + // The builder must not outlive the allocator + explicit JsonBuilder(ArduinoJson::Allocator *allocator); +#endif + JsonObject root() { if (!root_created_) { root_ = doc_.to(); @@ -176,17 +258,16 @@ class JsonBuilder { return root_; } + /// Serialize into a caller owned buffer. Returns the length; cap or more means it did not fit + /// and buf holds a truncated, unterminated copy. An overflowed document serializes as "{}". + size_t serialize_to(char *buf, size_t cap); + /// Serialize the JSON document to a SerializationBuffer (stack-first allocation) /// Uses 512-byte stack buffer by default, falls back to heap for larger JSON SerializationBuffer<> serialize(); private: -#ifdef USE_PSRAM - SpiRamAllocator allocator_; - JsonDocument doc_{&allocator_}; -#else JsonDocument doc_; -#endif JsonObject root_; bool root_created_{false}; }; diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index bf47b6df888..dedd960c368 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TRIGGER_ID, ) -from esphome.core import ID from esphome.cpp_generator import MockObj, literal from esphome.types import ConfigType, TemplateArgsType @@ -36,8 +35,6 @@ CONF_ON_RESULT = "on_result" key_collector_ns = cg.esphome_ns.namespace("key_collector") KeyCollector = key_collector_ns.class_("KeyCollector", cg.Component) -EnableAction = key_collector_ns.class_("EnableAction", automation.Action) -DisableAction = key_collector_ns.class_("DisableAction", automation.Action) X_TYPE = cg.std_string_ref.operator("const") @@ -135,43 +132,19 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_enabled(config[CONF_ENABLE_ON_BOOT])) -@automation.register_action( +KEY_COLLECTOR_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(KeyCollector), + } +) + +automation.register_apply_action( "key_collector.enable", - EnableAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(KeyCollector), - } - ), - synchronous=True, + KEY_COLLECTOR_ACTION_SCHEMA, + automation.ApplyCall("set_enabled(true)"), ) -async def enable_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]) - return var - - -@automation.register_action( +automation.register_apply_action( "key_collector.disable", - DisableAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(KeyCollector), - } - ), - synchronous=True, + KEY_COLLECTOR_ACTION_SCHEMA, + automation.ApplyCall("set_enabled(false)"), ) -async def disable_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]) - return var diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index c9eeabeb2de..e49cac46108 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -54,12 +54,4 @@ class KeyCollector final : public Component { bool enabled_{}; }; -template class EnableAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_enabled(true); } -}; - -template class DisableAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_enabled(false); } -}; - } // namespace esphome::key_collector diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 51d23991e28..40e7447d669 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -101,31 +101,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_PH in config: - conf = config[CONF_PH] - sens = await sensor.new_sensor(conf) - cg.add(var.set_ph_sensor(sens)) - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens)) - if CONF_DIS1 in config: - conf = config[CONF_DIS1] - sens = await sensor.new_sensor(conf) - cg.add(var.set_dis1_sensor(sens)) - if CONF_DIS2 in config: - conf = config[CONF_DIS2] - sens = await sensor.new_sensor(conf) - cg.add(var.set_dis2_sensor(sens)) - if CONF_REDOX in config: - conf = config[CONF_REDOX] - sens = await sensor.new_sensor(conf) - cg.add(var.set_redox_sensor(sens)) - if CONF_EC in config: - conf = config[CONF_EC] - sens = await sensor.new_sensor(conf) - cg.add(var.set_ec_sensor(sens)) - if CONF_OCI in config: - conf = config[CONF_OCI] - sens = await sensor.new_sensor(conf) - cg.add(var.set_oci_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PH, var.set_ph_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_DIS1, var.set_dis1_sensor) + await sensors(CONF_DIS2, var.set_dis2_sensor) + await sensors(CONF_REDOX, var.set_redox_sensor) + await sensors(CONF_EC, var.set_ec_sensor) + await sensors(CONF_OCI, var.set_oci_sensor) diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index a5dda6ca437..36e5bce8e3c 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -275,8 +275,4 @@ void Lc709203f::set_pack_size(uint16_t pack_size) { // not cause an error or crash, so I am not doing any additional checking here. } -void Lc709203f::set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } - -void Lc709203f::set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } - } // namespace esphome::lc709203f diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 46f773873af..e9c60e285f5 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -26,8 +26,8 @@ class Lc709203f final : public sensor::Sensor, public PollingComponent, public i void dump_config() override; void set_pack_size(uint16_t pack_size); - void set_thermistor_b_constant(uint16_t b_constant); - void set_pack_voltage(LC709203FBatteryVoltage pack_voltage); + void set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } + void set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) { battery_remaining_sensor_ = battery_remaining_sensor; diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 19786f38d32..d3179d9b302 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_THROTTLE, CONF_TIMEOUT -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -86,33 +84,15 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( # Actions -BluetoothPasswordSetAction = ld2410_ns.class_( - "BluetoothPasswordSetAction", automation.Action -) - - BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(LD2410Component), - cv.Required(CONF_PASSWORD): cv.templatable(cv.string_strict), + cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string_strict)), } ) - -@automation.register_action( +automation.register_apply_action( "bluetooth_password.set", - BluetoothPasswordSetAction, BLUETOOTH_PASSWORD_SET_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_PASSWORD, "set_bluetooth_password", cg.std_string), ) -async def bluetooth_password_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_PASSWORD], args, cg.std_string) - cg.add(var.set_password(template_)) - return var diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h deleted file mode 100644 index b0b9591d378..00000000000 --- a/esphome/components/ld2410/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "ld2410.h" - -namespace esphome::ld2410 { - -template class BluetoothPasswordSetAction final : public Action { - public: - explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} - TEMPLATABLE_VALUE(std::string, password) - - void play(const Ts &...x) override { this->ld2410_comp_->set_bluetooth_password(this->password_.value(x...)); } - - protected: - LD2410Component *ld2410_comp_; -}; - -} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index 2b687335320..f764e89f9b6 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -48,16 +48,11 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(ld2410_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(ld2410_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(ld2410_component.set_still_target_binary_sensor(sens)) - if out_pin_presence_status_config := config.get(CONF_OUT_PIN_PRESENCE_STATUS): - sens = await binary_sensor.new_binary_sensor(out_pin_presence_status_config) - cg.add(ld2410_component.set_out_pin_presence_status_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + await binary_sensors( + CONF_OUT_PIN_PRESENCE_STATUS, hub.set_out_pin_presence_status_binary_sensor + ) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index 59a95583318..3826455986d 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -46,16 +46,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_restart_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_query_button(b)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 061846f1f19..6c7601df3d2 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -24,7 +24,6 @@ #endif #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" #include "esphome/core/helpers.h" #include diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 3500d704a1e..b1891a5935a 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -87,31 +87,32 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number( - timeout_config, min_value=0, max_value=65535, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_timeout_number(n)) - if max_move_distance_gate_config := config.get(CONF_MAX_MOVE_DISTANCE_GATE): - n = await number.new_number( - max_move_distance_gate_config, min_value=2, max_value=8, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_max_move_distance_gate_number(n)) - if max_still_distance_gate_config := config.get(CONF_MAX_STILL_DISTANCE_GATE): - n = await number.new_number( - max_still_distance_gate_config, min_value=2, max_value=8, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_max_still_distance_gate_number(n)) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_light_threshold_number(n)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_TIMEOUT, hub.set_timeout_number, min_value=0, max_value=65535, step=1 + ) + await numbers( + CONF_MAX_MOVE_DISTANCE_GATE, + hub.set_max_move_distance_gate_number, + min_value=2, + max_value=8, + step=1, + ) + await numbers( + CONF_MAX_STILL_DISTANCE_GATE, + hub.set_max_still_distance_gate_number, + min_value=2, + max_value=8, + step=1, + ) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) for x in range(9): if gate_conf := config.get(f"g{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] @@ -119,13 +120,13 @@ async def to_code(config: ConfigType) -> None: await number.register_number( n, move_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_gate_move_threshold_number(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] n = cg.new_Pvariable(still_config[CONF_ID], x) await number.register_number( n, still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_gate_still_threshold_number(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index e89e3d59976..936ad83d39d 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -50,36 +50,32 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_distance_resolution_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_out_pin_level_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_light_function_select(s)) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_baud_rate_select(s)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.75m"], + ) + await selects( + CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index ca42b3a1d30..5b523d09442 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -157,30 +157,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if moving_distance_config := config.get(CONF_MOVING_DISTANCE): - sens = await sensor.new_sensor(moving_distance_config) - cg.add(ld2410_component.set_moving_target_distance_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(ld2410_component.set_still_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(ld2410_component.set_moving_target_energy_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(ld2410_component.set_still_target_energy_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(ld2410_component.set_light_sensor(sens)) - if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): - sens = await sensor.new_sensor(detection_distance_config) - cg.add(ld2410_component.set_detection_distance_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) for x in range(9): if gate_conf := config.get(f"g{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(ld2410_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(ld2410_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 6d8053ddd60..646890d43f5 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_engineering_mode_switch(s)) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_bluetooth_switch(s)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) diff --git a/esphome/components/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index 25c61a4825f..8a4e5186ef4 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(ld2410_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(ld2410_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 80cff014c0d..9e937275499 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -50,24 +50,12 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if dynamic_background_correction_status_config := config.get( - CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS - ): - sens = await binary_sensor.new_binary_sensor( - dynamic_background_correction_status_config - ) - cg.add( - LD2412_component.set_dynamic_background_correction_status_binary_sensor( - sens - ) - ) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(LD2412_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(LD2412_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(LD2412_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors( + CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS, + hub.set_dynamic_background_correction_status_binary_sensor, + ) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index 5a1ea2e6a5d..9f5c868310b 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -56,22 +56,12 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_factory_reset_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_query_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_restart_button(b)) - if start_dynamic_background_correction_config := config.get( - CONF_START_DYNAMIC_BACKGROUND_CORRECTION - ): - b = await button.new_button(start_dynamic_background_correction_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_start_dynamic_background_correction_button(b)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons( + CONF_START_DYNAMIC_BACKGROUND_CORRECTION, + hub.set_start_dynamic_background_correction_button, + ) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index 1a81c330adf..0c0c01c69cc 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -87,42 +87,45 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_threshold_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_DISTANCE_GATE): - n = await number.new_number( - max_distance_gate_config, min_value=2, max_value=13, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_max_distance_gate_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_DISTANCE_GATE): - n = await number.new_number( - min_distance_gate_config, min_value=1, max_value=12, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_min_distance_gate_number(n)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) + await numbers( + CONF_MAX_DISTANCE_GATE, + hub.set_max_distance_gate_number, + min_value=2, + max_value=13, + step=1, + ) + await numbers( + CONF_MIN_DISTANCE_GATE, + hub.set_min_distance_gate_number, + min_value=1, + max_value=12, + step=1, + ) for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID], x) - await number.register_number( - n, move_config, min_value=0, max_value=100, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + n = await number.new_number(move_config, min_value=0, max_value=100, step=1) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID], x) - await number.register_number( - n, still_config, min_value=0, max_value=100, step=1 + n = await number.new_number( + still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_timeout_number(n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) + await numbers( + CONF_TIMEOUT, + hub.set_timeout_number, + min_value=0, + max_value=900, + step=1, + ) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 8d12bad1151..a0a525a8107 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -2,8 +2,6 @@ namespace esphome::ld2412 { -GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} - void GateThresholdNumber::control(float value) { this->publish_state(value); this->parent_->set_gate_threshold(); diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 918b6dfad1a..308da43a34d 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -7,10 +7,10 @@ namespace esphome::ld2412 { class GateThresholdNumber final : public number::Number, public Parented { public: - GateThresholdNumber(uint8_t gate); + // Not "= default": that makes new(p) T() zero-fill the object at every codegen site before the ctor runs. + GateThresholdNumber() {} protected: - uint8_t gate_; void control(float value) override; }; diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index f62d523af38..710b47957c1 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightThresholdNumber final : public number::Number, public Parented { public: - LightThresholdNumber() = default; + // User provided, not "= default": `new(p) LightThresholdNumber()` would zero-fill .bss that is already zero. + LightThresholdNumber() {} protected: void control(float value) override; diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 02ecf2c30f5..40a075e5d54 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -50,36 +50,34 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_baud_rate_select(s)) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.5m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_distance_resolution_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_function_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_out_pin_level_select(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.5m", "0.75m"], + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_OUT_PIN_LEVEL, + hub.set_out_pin_level_select, + options=["low", "high"], + ) diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 46ec9be1d1e..527b1a1e934 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BaudRateSelect final : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index be8dba90b5d..d1bc15dea93 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class DistanceResolutionSelect final : public select::Select, public Parented { public: - DistanceResolutionSelect() = default; + // User provided, not "= default": `new(p) DistanceResolutionSelect()` would zero-fill .bss that is already zero. + DistanceResolutionSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index c8988fda78e..0867f3b1c2e 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightOutControlSelect final : public select::Select, public Parented { public: - LightOutControlSelect() = default; + // User provided, not "= default": `new(p) LightOutControlSelect()` would zero-fill .bss that is already zero. + LightOutControlSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index 0b6e676931d..e236a7d5f38 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -158,30 +158,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): - sens = await sensor.new_sensor(detection_distance_config) - cg.add(LD2412_component.set_detection_distance_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(LD2412_component.set_light_sensor(sens)) - if moving_distance_config := config.get(CONF_MOVING_DISTANCE): - sens = await sensor.new_sensor(moving_distance_config) - cg.add(LD2412_component.set_moving_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(LD2412_component.set_moving_target_energy_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(LD2412_component.set_still_target_distance_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(LD2412_component.set_still_target_energy_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) for x in range(14): if gate_conf := config.get(f"gate_{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(LD2412_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(LD2412_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index e7f71222fdb..d0643055972 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_bluetooth_switch(s)) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_engineering_mode_switch(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 8fd4a86e43c..e753613cdfa 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BluetoothSwitch final : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index defeb4c76ba..279128ddbc5 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: - EngineeringModeSwitch() = default; + // User provided, not "= default": `new(p) EngineeringModeSwitch()` would zero-fill .bss that is already zero. + EngineeringModeSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index c8e9f42ef39..63856d9f002 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(LD2412_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(LD2412_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) diff --git a/esphome/components/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 76b42c03628..ca3891a97c0 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -27,8 +27,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_HAS_TARGET in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_HAS_TARGET]) - cg.add(var.set_presence_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, var.set_presence_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py index cfcffd0922d..533f3b91aa3 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -52,20 +52,9 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2420_component = await cg.get_variable(config[CONF_LD2420_ID]) - if apply_config := config.get(CONF_APPLY_CONFIG): - b = await button.new_button(apply_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_apply_config_button(b)) - if revert_config := config.get(CONF_REVERT_CONFIG): - b = await button.new_button(revert_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_revert_config_button(b)) - if restart_config := config.get(CONF_RESTART_MODULE): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_restart_module_button(b)) - if factory_reset := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_factory_reset_button(b)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_APPLY_CONFIG, hub.set_apply_config_button) + await buttons(CONF_REVERT_CONFIG, hub.set_revert_config_button) + await buttons(CONF_RESTART_MODULE, hub.set_restart_module_button) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index 448639c9112..200f4e202fb 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -115,56 +115,65 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) - if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): - n = await number.new_number( - gate_timeout_config, min_value=0, max_value=255, step=5 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_timeout_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_GATE_DISTANCE): - n = await number.new_number( - min_distance_gate_config, min_value=0, max_value=15, step=1 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_min_gate_distance_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_GATE_DISTANCE): - n = await number.new_number( - max_distance_gate_config, min_value=1, max_value=15, step=1 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_max_gate_distance_number(n)) - if gate_move_sensitivity_config := config.get(CONF_GATE_MOVE_SENSITIVITY): - n = await number.new_number( - gate_move_sensitivity_config, min_value=0.05, max_value=1, step=0.025 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_sensitivity_factor_number(n)) - if gate_still_sensitivity_config := config.get(CONF_GATE_STILL_SENSITIVITY): - n = await number.new_number( - gate_still_sensitivity_config, min_value=0.05, max_value=1, step=0.025 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_sensitivity_factor_number(n)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_gate_timeout_number, + min_value=0, + max_value=255, + step=5, + ) + await numbers( + CONF_MIN_GATE_DISTANCE, + hub.set_min_gate_distance_number, + min_value=0, + max_value=15, + step=1, + ) + await numbers( + CONF_MAX_GATE_DISTANCE, + hub.set_max_gate_distance_number, + min_value=1, + max_value=15, + step=1, + ) + await numbers( + CONF_GATE_MOVE_SENSITIVITY, + hub.set_gate_move_sensitivity_factor_number, + min_value=0.05, + max_value=1, + step=0.025, + ) + await numbers( + CONF_GATE_STILL_SENSITIVITY, + hub.set_gate_still_sensitivity_factor_number, + min_value=0.05, + max_value=1, + step=0.025, + ) if config.get(CONF_GATE_SELECT): - if gate_number := config.get(CONF_GATE_SELECT): - n = await number.new_number(gate_number, min_value=0, max_value=15, step=1) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_select_number(n)) + await numbers( + CONF_GATE_SELECT, + hub.set_gate_select_number, + min_value=0, + max_value=15, + step=1, + ) if gate_still_threshold := config.get(CONF_STILL_THRESHOLD): n = cg.new_Pvariable(gate_still_threshold[CONF_ID]) await number.register_number( n, gate_still_threshold, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_threshold_numbers(0, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_numbers(0, n)) if gate_move_threshold := config.get(CONF_MOVE_THRESHOLD): n = cg.new_Pvariable(gate_move_threshold[CONF_ID]) await number.register_number( n, gate_move_threshold, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_threshold_numbers(0, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_numbers(0, n)) else: for x in range(16): if gate_conf := config.get(f"gate_{x}"): @@ -173,13 +182,13 @@ async def to_code(config: ConfigType) -> None: await number.register_number( n, move_config, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_threshold_numbers(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_numbers(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] n = cg.new_Pvariable(still_config[CONF_ID], x) await number.register_number( n, still_config, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_threshold_numbers(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_numbers(x, n)) diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index cd66064e475..6f3c73b9d12 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -25,11 +25,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) - if operating_mode_config := config.get(CONF_OPERATING_MODE): - sel = await select.new_select( - operating_mode_config, - options=CONF_SELECTS, - ) - await cg.register_parented(sel, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_operating_mode_select(sel)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_OPERATING_MODE, hub.set_operating_mode_select, options=CONF_SELECTS + ) diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index f98d63585b6..89cfd3a5153 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -34,11 +34,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_MOVING_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_MOVING_DISTANCE]) - cg.add(var.set_distance_sensor(sens)) - if CONF_GATE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_GATE_ENERGY]) - cg.add(var.set_energy_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOVING_DISTANCE, var.set_distance_sensor) + await sensors(CONF_GATE_ENERGY, var.set_energy_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index cee8f25c1fe..58ebc9cf5b1 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -28,8 +28,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_FW_VERSION in config: - sens = await text_sensor.new_text_sensor(config[CONF_FW_VERSION]) - cg.add(var.set_fw_version_text_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_FW_VERSION, var.set_fw_version_text_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 779d151fd91..c7c1b6b46af 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -41,13 +41,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(ld2450_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(ld2450_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(ld2450_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 42cadd2052d..d23f1bebf8f 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 392fc67ffdd..71dc19a6cd3 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: - FactoryResetButton() = default; + // User provided, not "= default": `new(p) FactoryResetButton()` would zero-fill .bss that is already zero. + FactoryResetButton() {} protected: void press_action() override; diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index 9219011f8ba..87b1a2bbd1e 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: - RestartButton() = default; + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} protected: void press_action() override; diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 4f242076d66..237c7c4c231 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -80,16 +80,15 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): - n = await number.new_number( - presence_timeout_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_presence_timeout_number(n)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_presence_timeout_number, + min_value=0, + max_value=3600, + step=1, + ) for zone_num in range(MAX_ZONES): if zone_conf := config.get(f"zone_{zone_num + 1}"): zone_x1_config = zone_conf.get(CONF_X1) @@ -97,27 +96,27 @@ async def to_code(config: ConfigType) -> None: await number.register_number( x1, zone_x1_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x1, config[CONF_LD2450_ID]) + await cg.register_parented(x1, hub) zone_y1_config = zone_conf.get(CONF_Y1) y1 = cg.new_Pvariable(zone_y1_config[CONF_ID], zone_num) await number.register_number( y1, zone_y1_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y1, config[CONF_LD2450_ID]) + await cg.register_parented(y1, hub) zone_x2_config = zone_conf.get(CONF_X2) x2 = cg.new_Pvariable(zone_x2_config[CONF_ID], zone_num) await number.register_number( x2, zone_x2_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x2, config[CONF_LD2450_ID]) + await cg.register_parented(x2, hub) zone_y2_config = zone_conf.get(CONF_Y2) y2 = cg.new_Pvariable(zone_y2_config[CONF_ID], zone_num) await number.register_number( y2, zone_y2_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y2, config[CONF_LD2450_ID]) + await cg.register_parented(y2, hub) - cg.add(ld2450_component.set_zone_numbers(zone_num, x1, y1, x2, y2)) + cg.add(hub.set_zone_numbers(zone_num, x1, y1, x2, y2)) diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index 09c8afca55b..8c44fa39dc0 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: - PresenceTimeoutNumber() = default; + // User provided, not "= default": `new(p) PresenceTimeoutNumber()` would zero-fill .bss that is already zero. + PresenceTimeoutNumber() {} protected: void control(float value) override; diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index d91b42426ad..779dfc2a35c 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -33,31 +33,28 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_baud_rate_select(s)) - if zone_type_config := config.get(CONF_ZONE_TYPE): - s = await select.new_select( - zone_type_config, - options=[ - "Disabled", - "Detection", - "Filter", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_zone_type_select(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_ZONE_TYPE, + hub.set_zone_type_select, + options=[ + "Disabled", + "Detection", + "Filter", + ], + ) diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index cb531181707..af4c477dff2 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index 566346eb482..cf79c2324dc 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: - ZoneTypeSelect() = default; + // User provided, not "= default": `new(p) ZoneTypeSelect()` would zero-fill .bss that is already zero. + ZoneTypeSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 40462e202d1..e714d353d1f 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -228,47 +228,40 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) + hub = await cg.get_variable(config[CONF_LD2450_ID]) - if target_count_config := config.get(CONF_TARGET_COUNT): - sens = await sensor.new_sensor(target_count_config) - cg.add(ld2450_component.set_target_count_sensor(sens)) - - if still_target_count_config := config.get(CONF_STILL_TARGET_COUNT): - sens = await sensor.new_sensor(still_target_count_config) - cg.add(ld2450_component.set_still_target_count_sensor(sens)) - - if moving_target_count_config := config.get(CONF_MOVING_TARGET_COUNT): - sens = await sensor.new_sensor(moving_target_count_config) - cg.add(ld2450_component.set_moving_target_count_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + await sensors(CONF_STILL_TARGET_COUNT, hub.set_still_target_count_sensor) + await sensors(CONF_MOVING_TARGET_COUNT, hub.set_moving_target_count_sensor) for n in range(MAX_TARGETS): if target_conf := config.get(f"target_{n + 1}"): if x_config := target_conf.get(CONF_X): sens = await sensor.new_sensor(x_config) - cg.add(ld2450_component.set_move_x_sensor(n, sens)) + cg.add(hub.set_move_x_sensor(n, sens)) if y_config := target_conf.get(CONF_Y): sens = await sensor.new_sensor(y_config) - cg.add(ld2450_component.set_move_y_sensor(n, sens)) + cg.add(hub.set_move_y_sensor(n, sens)) if speed_config := target_conf.get(CONF_SPEED): sens = await sensor.new_sensor(speed_config) - cg.add(ld2450_component.set_move_speed_sensor(n, sens)) + cg.add(hub.set_move_speed_sensor(n, sens)) if angle_config := target_conf.get(CONF_ANGLE): sens = await sensor.new_sensor(angle_config) - cg.add(ld2450_component.set_move_angle_sensor(n, sens)) + cg.add(hub.set_move_angle_sensor(n, sens)) if distance_config := target_conf.get(CONF_DISTANCE): sens = await sensor.new_sensor(distance_config) - cg.add(ld2450_component.set_move_distance_sensor(n, sens)) + cg.add(hub.set_move_distance_sensor(n, sens)) if resolution_config := target_conf.get(CONF_RESOLUTION): sens = await sensor.new_sensor(resolution_config) - cg.add(ld2450_component.set_move_resolution_sensor(n, sens)) + cg.add(hub.set_move_resolution_sensor(n, sens)) for n in range(MAX_ZONES): if zone_config := config.get(f"zone_{n + 1}"): if target_count_config := zone_config.get(CONF_TARGET_COUNT): sens = await sensor.new_sensor(target_count_config) - cg.add(ld2450_component.set_zone_target_count_sensor(n, sens)) + cg.add(hub.set_zone_target_count_sensor(n, sens)) if still_target_count_config := zone_config.get(CONF_STILL_TARGET_COUNT): sens = await sensor.new_sensor(still_target_count_config) - cg.add(ld2450_component.set_zone_still_target_count_sensor(n, sens)) + cg.add(hub.set_zone_still_target_count_sensor(n, sens)) if moving_target_count_config := zone_config.get(CONF_MOVING_TARGET_COUNT): sens = await sensor.new_sensor(moving_target_count_config) - cg.add(ld2450_component.set_zone_moving_target_count_sensor(n, sens)) + cg.add(hub.set_zone_moving_target_count_sensor(n, sens)) diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 084f79ee1bc..bb920f6c76e 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_bluetooth_switch(s)) - if multi_target_config := config.get(CONF_MULTI_TARGET): - s = await switch.new_switch(multi_target_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_multi_target_switch(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_MULTI_TARGET, hub.set_multi_target_switch) diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3d48a89b57f..8b118a7b8c1 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index 739f308cce9..d711a2d2d29 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: - MultiTargetSwitch() = default; + // User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero. + MultiTargetSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index a8b978ef488..3eaf6c7fcad 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -51,16 +51,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(ld2450_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(ld2450_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) for n in range(MAX_TARGETS): if (direction_conf := config.get(f"target_{n + 1}")) and ( direction_config := direction_conf.get(CONF_DIRECTION) ): sens = await text_sensor.new_text_sensor(direction_config) - cg.add(ld2450_component.set_direction_text_sensor(n, sens)) + cg.add(hub.set_direction_text_sensor(n, sens)) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 74095d5dede..36405f06602 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -40,9 +40,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) - if target_config := config.get(CONF_TARGET): - sens = await binary_sensor.new_binary_sensor(target_config) - cg.add(hub.set_presence_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_TARGET, hub.set_presence_binary_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 73fc7df3311..aa34ad9d392 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -301,14 +301,10 @@ void LD6002BComponent::setup() { target_display_controlled = true; // Nothing reports this switch back, so its restored state is the only state // there is. Restoring through the switch keeps its inversion in the path: - // the restored value is logical, and turn_on()/turn_off() are what turn it + // the restored value is logical, and driving the switch is what turns it // into the raw command, the published state and the stream flag. const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); - if (state) { - this->target_display_switch_->turn_on(); - } else { - this->target_display_switch_->turn_off(); - } + this->target_display_switch_->control(state); } #endif if (!target_display_controlled) { @@ -328,11 +324,7 @@ void LD6002BComponent::setup() { // The switch owns the stream, so it is also what applies the restored state: // driving it rather than the module keeps the entity's inversion in the path. const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->point_cloud_switch_->turn_on(); - } else { - this->point_cloud_switch_->turn_off(); - } + this->point_cloud_switch_->control(state); } #endif if (!point_cloud_controlled) { @@ -375,11 +367,7 @@ void LD6002BComponent::setup() { // Driving the switch applies its inversion; it also marks the restored value // as reported, so the work mode fallback runs on that until the query lands. const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->low_power_switch_->turn_on(); - } else { - this->low_power_switch_->turn_off(); - } + this->low_power_switch_->control(state); } #else bool want_low_power = false; diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index cceefb3837a..5653f8b9449 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -154,13 +154,9 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) - if target_count_config := config.get(CONF_TARGET_COUNT): - sens = await sensor.new_sensor(target_count_config) - cg.add(hub.set_target_count_sensor(sens)) - - if point_count_config := config.get(CONF_POINT_COUNT): - sens = await sensor.new_sensor(point_count_config) - cg.add(hub.set_point_count_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + await sensors(CONF_POINT_COUNT, hub.set_point_count_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py index 0e8e2e80e7e..de3dde15cca 100644 --- a/esphome/components/ld6002b/text_sensor.py +++ b/esphome/components/ld6002b/text_sensor.py @@ -24,9 +24,6 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) - if work_mode_config := config.get(CONF_WORK_MODE): - sens = await text_sensor.new_text_sensor(work_mode_config) - cg.add(hub.set_work_mode_text_sensor(sens)) - if ota_config := config.get(CONF_OTA_VERSION): - sens = await text_sensor.new_text_sensor(ota_config) - cg.add(hub.set_ota_version_text_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_WORK_MODE, hub.set_work_mode_text_sensor) + await text_sensors(CONF_OTA_VERSION, hub.set_ota_version_text_sensor) diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b0a243f2e43..de63325455b 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" #include @@ -43,20 +42,6 @@ class LEDCOutput final : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - protected: - LEDCOutput *parent_; -}; - } // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index e5e7c3dcbe4..bb7f8de3fe8 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -12,8 +12,6 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["esp32"] @@ -45,7 +43,6 @@ def validate_frequency(value: Any) -> float: ledc_ns = cg.esphome_ns.namespace("ledc") LEDCOutput = ledc_ns.class_("LEDCOutput", output.FloatOutput, cg.Component) -SetFrequencyAction = ledc_ns.class_("SetFrequencyAction", automation.Action) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -77,25 +74,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_phase_angle(config[CONF_PHASE_ANGLE])) -@automation.register_action( +automation.register_apply_action( "output.ledc.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(LEDCOutput), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def ledc_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 50dc7877990..8cd7f3e7b62 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -513,6 +513,8 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # Must run before the platform's builder scripts are loaded; see the script. + cg.add_platformio_option("extra_scripts", ["pre:scons_dont_inherit.py"]) cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) # 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 @@ -618,4 +620,8 @@ def copy_files() -> None: patch_linker_file, CORE.relative_build_path("patch_linker.py"), ) + copy_file_if_changed( + script_dir / "scons_dont_inherit.py.script", + CORE.relative_build_path("scons_dont_inherit.py"), + ) copy_ccache_script() diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index b1a37cb2258..ca88835f695 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -8,6 +8,8 @@ namespace esphome::libretiny { static const char *const TAG = "libretiny.gpio"; static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { + flags = + flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); if (flags == gpio::FLAG_INPUT) { return INPUT; } else if (flags == gpio::FLAG_OUTPUT) { diff --git a/esphome/components/libretiny/scons_dont_inherit.py.script b/esphome/components/libretiny/scons_dont_inherit.py.script new file mode 100644 index 00000000000..23da6ff3968 --- /dev/null +++ b/esphome/components/libretiny/scons_dont_inherit.py.script @@ -0,0 +1,16 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import builtins +import functools +import sys + +# SCons 4.11 compiles SConscript files without dont_inherit=True, so its own +# `from __future__ import annotations` leaks into every builder script and +# turns their annotations into strings. That breaks LibreTiny's Library +# dataclass, whose InitVar is then no longer recognised. Runs before the +# platform's builder scripts are loaded. The module comes from sys.modules +# because SCons.Script also exports a function named SConscript. +sconscript = sys.modules["SCons.Script.SConscript"] +if sconscript.__dict__.get("compile") is None: + sconscript.compile = functools.partial(builtins.compile, dont_inherit=True) diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f0ea0228b7d..0b3d55f6d2a 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" #ifdef USE_LIBRETINY @@ -34,20 +33,6 @@ class LibreTinyPWM final : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - protected: - LibreTinyPWM *parent_; -}; - } // namespace esphome::libretiny_pwm #endif diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 716ccfad2b2..e8be88df60f 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,15 +3,12 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] libretinypwm_ns = cg.esphome_ns.namespace("libretiny_pwm") LibreTinyPWM = libretinypwm_ns.class_("LibreTinyPWM", output.FloatOutput, cg.Component) -SetFrequencyAction = libretinypwm_ns.class_("SetFrequencyAction", automation.Action) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -32,25 +29,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(config[CONF_FREQUENCY])) -@automation.register_action( +automation.register_apply_action( "output.libretiny_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(LibreTinyPWM), cv.Required(CONF_FREQUENCY): cv.templatable(cv.int_), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def libretiny_pwm_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a3..b175620bbf4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -10,20 +10,13 @@ from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( - CONF_BLUE, - CONF_BRIGHTNESS, - CONF_COLD_WHITE, CONF_COLD_WHITE_COLOR_TEMPERATURE, - CONF_COLOR_BRIGHTNESS, CONF_COLOR_CORRECT, - CONF_COLOR_MODE, - CONF_COLOR_TEMPERATURE, CONF_DEFAULT_TRANSITION_LENGTH, CONF_EFFECTS, CONF_ENTITY_CATEGORY, CONF_FLASH_TRANSITION_LENGTH, CONF_GAMMA_CORRECT, - CONF_GREEN, CONF_ICON, CONF_ID, CONF_INITIAL_STATE, @@ -35,17 +28,14 @@ from esphome.const import ( CONF_ON_TURN_ON, CONF_OUTPUT_ID, CONF_POWER_SUPPLY, - CONF_RED, CONF_RESTORE_MODE, + CONF_RESTORE_STATE, CONF_RGB_ORDER, - CONF_STATE, CONF_TRIGGER_ID, - CONF_WARM_WHITE, CONF_WARM_WHITE_COLOR_TEMPERATURE, CONF_WEB_SERVER, - CONF_WHITE, ) -from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, @@ -64,11 +54,23 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) +from .restore_state import ( + LEGACY_RESTORE_MODES, + RESTORE_STATE_NONE, + RESTORE_STATE_SCHEMA, + _build_state_lambda, + _initial_state_overridden_by_legacy_mode, + _initial_state_statements, + _legacy_cold_boot_statements, + _legacy_restore_statements, + _restore_state_statements, +) from .types import ( # noqa: F401 AddressableLight, AddressableLightState, ChannelColors, ColorMode, + GammaTable, LightOutput, LightState, LightStateRTCState, @@ -84,6 +86,8 @@ CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True DOMAIN = "light" +CONF_GAMMA_TABLE_ID = "gamma_table_id" +CONF_RESUME_EFFECT = "resume_effect" @dataclass @@ -139,18 +143,29 @@ def generate_gamma_table(gamma_correct: float) -> list[HexInt]: return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)] -def _get_or_create_gamma_table(gamma_correct): +def gamma_table_initializer(gamma_correct: float) -> str: + """C++ initializer for a light::GammaTable: the lookup table, then gamma * 100.""" + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(gamma_correct)) + # gamma_x100 is a uint16_t; platforms that redefine gamma_correct leave it unbounded, so saturate here + return f"{{{{{lut}}}, {min(0xFFFF, round(gamma_correct * 100))}}}" + + +def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpression: data = _get_data() if gamma_correct in data.gamma_tables: return data.gamma_tables[gamma_correct] - forward = generate_gamma_table(gamma_correct) - - gamma_str = f"{gamma_correct}".replace(".", "_") - fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16) - fwd_arr = cg.progmem_array(fwd_id, forward) - data.gamma_tables[gamma_correct] = fwd_arr - return fwd_arr + # table_id is generated and resolved against every declared ID, so it can't collide with a + # YAML ID; lights sharing a gamma reuse the first light's table. + cg.add( + cg.RawStatement( + f"static constexpr light::GammaTable {table_id} PROGMEM = " + f"{gamma_table_initializer(gamma_correct)};" + ) + ) + table = cg.RawExpression(f"&{table_id}") + data.gamma_tables[gamma_correct] = table + return table def find_effect_index(effects: list, effect_name: str) -> int | None: @@ -273,17 +288,34 @@ def migrate_channel_colors( def _final_validate(config: ConfigType) -> None: - """Validate all recorded effect name references against their target lights. + """Validate every configured light's own resolved config, and all recorded + effect name references against their target lights. - This runs once per light platform instance. If no light platform is configured, - this never runs — but the ID validator will catch the missing light ID separately. + FINAL_VALIDATE_SCHEMA for a platform-based domain like `light:` runs once for + the whole domain, not once per entry -- `config` is the full list of light + platform entries across the file, not a single light's own config. """ + for light_config in config: + restore_mode = light_config.get(CONF_RESTORE_MODE) + if restore_mode is not None: + legacy = LEGACY_RESTORE_MODES[restore_mode] + if _initial_state_overridden_by_legacy_mode( + legacy, light_config.get(CONF_INITIAL_STATE) + ): + _LOGGER.warning( + "[%s] 'initial_state: state' is ignored because 'restore_mode: %s' " + "always sets the light %s at boot; use 'restore_state:' instead for " + "per-field control", + light_config.get(CONF_NAME) or light_config[CONF_ID], + restore_mode, + "ON" if legacy.cold_boot_state else "OFF", + ) + data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: return - # Drain the lists so we only validate once even though - # FINAL_VALIDATE_SCHEMA runs for each light platform instance. + # Drain the lists so each recorded reference is only validated once. refs = data.effect_refs data.effect_refs = [] cycle_refs = data.effect_cycle_refs @@ -328,17 +360,10 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -LightRestoreMode = light_ns.enum("LightRestoreMode") -RESTORE_MODES = { - "RESTORE_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_DEFAULT_OFF, - "RESTORE_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_DEFAULT_ON, - "ALWAYS_OFF": LightRestoreMode.LIGHT_ALWAYS_OFF, - "ALWAYS_ON": LightRestoreMode.LIGHT_ALWAYS_ON, - "RESTORE_INVERTED_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_OFF, - "RESTORE_INVERTED_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_ON, - "RESTORE_AND_OFF": LightRestoreMode.LIGHT_RESTORE_AND_OFF, - "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, -} +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" +CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval" LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) @@ -346,12 +371,14 @@ LIGHT_SCHEMA = ( .extend( { cv.GenerateID(): cv.declare_id(LightState), + cv.GenerateID(CONF_GAMMA_TABLE_ID): cv.declare_id(GammaTable), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTJSONLightComponent ), - cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( - RESTORE_MODES, upper=True, space="_" + cv.Exclusive(CONF_RESTORE_MODE, "restore"): cv.one_of( + *LEGACY_RESTORE_MODES, upper=True, space="_" ), + cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA, cv.Optional(CONF_ON_TURN_ON): auto.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightTurnOnTrigger), @@ -368,6 +395,7 @@ LIGHT_SCHEMA = ( } ), cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA, + cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean, } ) ) @@ -387,8 +415,13 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, + # Below 150ms a device cannot publish any faster and only spends CPU and traffic + cv.Optional(CONF_TRANSITION_STATE_PUBLISH_INTERVAL): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(milliseconds=150)), + ), cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } ) @@ -402,6 +435,11 @@ RGB_LIGHT_SCHEMA = BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( ADDRESSABLE_LIGHT_SCHEMA = RGB_LIGHT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(AddressableLightState), + # The addressable transformer writes the LED buffer directly, so there is no + # intermediate state to publish + cv.Optional(CONF_TRANSITION_STATE_PUBLISH_INTERVAL): cv.invalid( + "transition_state_publish_interval is not supported on addressable lights" + ), cv.Optional(CONF_EFFECTS): validate_effects(ADDRESSABLE_EFFECTS), cv.Optional(CONF_COLOR_CORRECT): cv.All( [cv.percentage], cv.Length(min=3, max=4) @@ -420,6 +458,28 @@ class LightType(enum.IntEnum): ADDRESSABLE = 3 +def _apply_default_restore_mode( + default_restore_mode: str, +) -> Callable[[ConfigType], ConfigType]: + # cv.Exclusive has no default, so apply the default here if neither key is configured. + def validator(config: ConfigType) -> ConfigType: + if CONF_RESTORE_MODE not in config and CONF_RESTORE_STATE not in config: + config[CONF_RESTORE_MODE] = cv.one_of( + *LEGACY_RESTORE_MODES, upper=True, space="_" + )(default_restore_mode) + return config + + return validator + + +_BASE_SCHEMAS: dict[LightType, cv.Schema] = { + LightType.BINARY: BINARY_LIGHT_SCHEMA, + LightType.BRIGHTNESS_ONLY: BRIGHTNESS_ONLY_LIGHT_SCHEMA, + LightType.RGB: RGB_LIGHT_SCHEMA, + LightType.ADDRESSABLE: ADDRESSABLE_LIGHT_SCHEMA, +} + + def light_schema( class_: MockObjClass, type_: LightType, @@ -435,25 +495,14 @@ def light_schema( for key, default, validator in [ (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), (CONF_ICON, icon, cv.icon), - ( - CONF_RESTORE_MODE, - default_restore_mode, - cv.enum(RESTORE_MODES, upper=True, space="_"), - ), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator - if type_ == LightType.BINARY: - return BINARY_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.BRIGHTNESS_ONLY: - return BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.RGB: - return RGB_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.ADDRESSABLE: - return ADDRESSABLE_LIGHT_SCHEMA.extend(schema) - - raise ValueError(f"Invalid light type: {type_}") + result = _BASE_SCHEMAS[type_].extend(schema) + if default_restore_mode is not cv.UNDEFINED: + result.add_extra(_apply_default_restore_mode(default_restore_mode)) + return result def validate_color_temperature_channels(value): @@ -472,49 +521,72 @@ def validate_color_temperature_channels(value): @setup_entity("light") async def setup_light_core_(light_var, config, output_var): - cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) + # All 8 legacy restore_mode values, and the restore_state key, are just different + # ways to build the same state callback and save_enabled flag that LightState's + # runtime actually understands. + initial_state_config = config.get(CONF_INITIAL_STATE) + initial_statements = await _initial_state_statements(initial_state_config) + if config[CONF_RESUME_EFFECT]: + cg.add_define("USE_LIGHT_RESUME_EFFECT") + cg.add(light_var.set_resume_effect(True)) - if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None: - # Emit a stateless lambda that constructs the initial state — values live - # in flash as code, not stored in the LightState object (~40 bytes saved). - initial_state = LightStateRTCState( - initial_state_config.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), - initial_state_config.get(CONF_STATE, False), - initial_state_config.get(CONF_BRIGHTNESS, 1.0), - initial_state_config.get(CONF_COLOR_BRIGHTNESS, 1.0), - initial_state_config.get(CONF_RED, 1.0), - initial_state_config.get(CONF_GREEN, 1.0), - initial_state_config.get(CONF_BLUE, 1.0), - initial_state_config.get(CONF_WHITE, 1.0), - initial_state_config.get(CONF_COLOR_TEMPERATURE, 1.0), - initial_state_config.get(CONF_COLD_WHITE, 1.0), - initial_state_config.get(CONF_WARM_WHITE, 1.0), + restore_mode = config.get(CONF_RESTORE_MODE) + restore_state_config = config.get(CONF_RESTORE_STATE) + if restore_state_config == RESTORE_STATE_NONE: + # restore_state: none is explicit shorthand for "no restoring at all" -- + restore_state_config = None + + if restore_mode is not None: + legacy = LEGACY_RESTORE_MODES[restore_mode] + initial_statements.extend( + _legacy_cold_boot_statements(legacy, initial_state_config) ) - args = [(LightStateRTCState.operator("ref"), "s")] - lamb = await cg.process_lambda( - Lambda(f"s = {initial_state};"), - args, - return_type=cg.void, + restore_statements = _legacy_restore_statements(legacy) + save_enabled = legacy.save_enabled + elif restore_state_config is not None: + restore_statements = await _restore_state_statements( + restore_state_config, initial_state_config ) - cg.add(light_var.set_initial_state(lamb)) + save_enabled = True + else: + # Neither key configured: no persistence, and no cold-boot forcing either. + restore_statements = [] + save_enabled = False + + if ( + lamb := await _build_state_lambda( + initial_statements, restore_statements, save_enabled + ) + ) is not None: + cg.add(light_var.set_state_callback(lamb)) + if save_enabled: # matches LightState::save_enabled_'s own default of false + cg.add(light_var.set_save_enabled(save_enabled)) if ( default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): + cg.add_define("USE_LIGHT_FLASH_TRANSITION_LENGTH") cg.add(light_var.set_flash_transition_length(flash_transition_length)) + # Setting an interval opts this light in and compiles the feature in + if (interval := config.get(CONF_TRANSITION_STATE_PUBLISH_INTERVAL)) is not None: + cg.add(light_var.set_transition_state_publish_interval(interval)) + cg.add_define("USE_LIGHT_TRANSITION_PUBLISH_INTERVAL") if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: - cg.add(light_var.set_gamma_correct(gamma_correct)) - fwd_arr = _get_or_create_gamma_table(gamma_correct) + fwd_arr = _get_or_create_gamma_table(gamma_correct, config[CONF_GAMMA_TABLE_ID]) cg.add(light_var.set_gamma_table(fwd_arr)) cg.add_define("USE_LIGHT_GAMMA_LUT") effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index ced15dfc603..2e963479f76 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -31,34 +31,6 @@ template class ToggleAction final : pu transition_length_{}; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `brightness: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction final : public Action { - public: - using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); - LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - this->apply_(this->parent_, call, x...); - call.perform(); - } - - protected: - LightState *parent_; - ApplyFn apply_; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} @@ -145,23 +117,6 @@ template class LightEffectCycleAction final : publ bool include_none_{false}; }; -template class LightIsOnCondition final : public Condition { - public: - explicit LightIsOnCondition(LightState *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } - - protected: - LightState *state_; -}; -template class LightIsOffCondition final : public Condition { - public: - explicit LightIsOffCondition(LightState *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } - - protected: - LightState *state_; -}; - class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 7eaba9b117a..a616d26571d 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,5 +1,10 @@ +from collections.abc import Callable +import logging +from typing import Any, NamedTuple + from esphome import automation import esphome.codegen as cg +from esphome.components.const.css_colors import CSS_COLORS from esphome.config import path_context import esphome.config_validation as cv from esphome.const import ( @@ -7,6 +12,7 @@ from esphome.const import ( CONF_BRIGHTNESS, CONF_BRIGHTNESS_LIMITS, CONF_COLD_WHITE, + CONF_COLOR, CONF_COLOR_BRIGHTNESS, CONF_COLOR_MODE, CONF_COLOR_TEMPERATURE, @@ -27,7 +33,8 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from .types import ( @@ -37,17 +44,37 @@ from .types import ( AddressableSet, ColorMode, DimRelativeAction, - LightCall, - LightControlAction, LightEffectCycleAction, - LightIsOffCondition, - LightIsOnCondition, LightState, ToggleAction, ) +_LOGGER = logging.getLogger(__name__) + CONF_INCLUDE_NONE = "include_none" +_STATE_ON_OFF = cv.one_of("ON", "OFF", upper=True) + + +@schema_extractor("one_of") +def validate_light_state(value: Any) -> Any: + """Validate a light on/off state. + + Documented as 'ON'/'OFF', but accepts all boolean forms for backward compatibility. + """ + if value == SCHEMA_EXTRACT: + return ("ON", "OFF") + try: + return _STATE_ON_OFF(value) == "ON" + except cv.Invalid: + pass + try: + return cv.boolean(value) + except cv.Invalid as err: + raise cv.Invalid( + f"Expected 'ON', 'OFF', or a boolean value, got {value!r}" + ) from err + @automation.register_action( "light.toggle", @@ -75,21 +102,103 @@ async def light_toggle_to_code(config, action_id, template_arg, args): return var +class LightStateField(NamedTuple): + """One field of a light's state: the single source for the schemas and boot-time + codegen that deal with it.""" + + conf_key: str + # Member of LightStateRTCState holding this field. + member: str + validator: Callable[[Any], Any] + # The color mode providing this field, if it implies one. + color_mode: MockObj | None = None + templatable: bool = True + + +# In LightStateRTCState member order. +LIGHT_STATE_FIELDS: tuple[LightStateField, ...] = ( + LightStateField(CONF_STATE, "state", validate_light_state), + LightStateField( + CONF_COLOR_MODE, + "color_mode", + cv.enum(COLOR_MODES, upper=True, space="_"), + templatable=False, + ), + LightStateField(CONF_BRIGHTNESS, "brightness", cv.percentage, ColorMode.BRIGHTNESS), + LightStateField( + CONF_COLOR_BRIGHTNESS, "color_brightness", cv.percentage, ColorMode.RGB + ), + LightStateField(CONF_RED, "red", cv.percentage, ColorMode.RGB), + LightStateField(CONF_GREEN, "green", cv.percentage, ColorMode.RGB), + LightStateField(CONF_BLUE, "blue", cv.percentage, ColorMode.RGB), + LightStateField(CONF_WHITE, "white", cv.percentage, ColorMode.WHITE), + LightStateField( + CONF_COLOR_TEMPERATURE, + "color_temp", + cv.color_temperature, + ColorMode.COLOR_TEMPERATURE, + ), + LightStateField( + CONF_COLD_WHITE, "cold_white", cv.percentage, ColorMode.COLD_WARM_WHITE + ), + LightStateField( + CONF_WARM_WHITE, "warm_white", cv.percentage, ColorMode.COLD_WARM_WHITE + ), +) + + +@schema_extractor("one_of") +def validate_color(value: Any) -> str | int: + """Validate a CSS color name or a 0xRRGGBB value.""" + if value == SCHEMA_EXTRACT: + return ["CSS color name", "hex color value"] + if isinstance(value, int) or ( + isinstance(value, str) and value.lower().startswith("0x") + ): + return cv.hex_int_range(0, 0xFFFFFF)(value) + return cv.one_of(*CSS_COLORS, lower=True)(value) + + +COLOR_SCHEMA: dict[cv.Optional, Any] = {cv.Optional(CONF_COLOR): validate_color} + + +def color_to_rgb(config: ConfigType) -> ConfigType: + """Replace a `color` CSS name or 0xRRGGBB value with red, green and blue values. + + The light scales its color so the brightest channel is at full level, so a dark + color is given as a full-level color plus a color brightness. + """ + if (color := config.pop(CONF_COLOR, None)) is None: + return config + if any(key in config for key in (CONF_RED, CONF_GREEN, CONF_BLUE)): + raise cv.Invalid( + f"'{CONF_COLOR}' cannot be used with '{CONF_RED}', '{CONF_GREEN}' or '{CONF_BLUE}'" + ) + rgb = color if isinstance(color, int) else CSS_COLORS[color] + channels = (rgb >> 16 & 0xFF, rgb >> 8 & 0xFF, rgb & 0xFF) + peak = max(channels) + if CONF_COLOR_BRIGHTNESS not in config: + config[CONF_COLOR_BRIGHTNESS] = peak / 255 + elif peak < 0xFF: + _LOGGER.warning( + "'%s' overrides the brightness of color '%s'", + CONF_COLOR_BRIGHTNESS, + f"0x{color:06X}" if isinstance(color, int) else color, + ) + for key, value in zip((CONF_RED, CONF_GREEN, CONF_BLUE), channels, strict=True): + config[key] = value / peak if peak else 0.0 + return config + + LIGHT_STATE_SCHEMA = cv.Schema( { - cv.Optional(CONF_COLOR_MODE): cv.enum(COLOR_MODES, upper=True, space="_"), - cv.Optional(CONF_STATE): cv.templatable(cv.boolean), - cv.Optional(CONF_BRIGHTNESS): cv.templatable(cv.percentage), - cv.Optional(CONF_COLOR_BRIGHTNESS): cv.templatable(cv.percentage), - cv.Optional(CONF_RED): cv.templatable(cv.percentage), - cv.Optional(CONF_GREEN): cv.templatable(cv.percentage), - cv.Optional(CONF_BLUE): cv.templatable(cv.percentage), - cv.Optional(CONF_WHITE): cv.templatable(cv.percentage), - cv.Optional(CONF_COLOR_TEMPERATURE): cv.templatable(cv.color_temperature), - cv.Optional(CONF_COLD_WHITE): cv.templatable(cv.percentage), - cv.Optional(CONF_WARM_WHITE): cv.templatable(cv.percentage), + cv.Optional(field.conf_key): ( + cv.templatable(field.validator) if field.templatable else field.validator + ) + for field in LIGHT_STATE_FIELDS } -) +).extend(COLOR_SCHEMA) +LIGHT_STATE_SCHEMA.add_extra(color_to_rgb) LIGHT_CONTROL_ACTION_SCHEMA = LIGHT_STATE_SCHEMA.extend( { @@ -147,7 +256,7 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -def _resolve_effect_index(config: ConfigType) -> int: +def _resolve_effect_index(config: ConfigType, original_name: str) -> int: """Resolve a static effect name to its 1-based index at codegen time. Effect index 0 means "None" (no effect). Effects are 1-indexed matching @@ -155,7 +264,6 @@ def _resolve_effect_index(config: ConfigType) -> int: """ from . import available_effects_str, find_effect_index - original_name = config[CONF_EFFECT] if original_name.lower() == "none": return 0 light_id = config[CONF_ID] @@ -173,87 +281,49 @@ def _resolve_effect_index(config: ConfigType) -> int: ) -@automation.register_action( - "light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, synchronous=True +def _effect_index(config: ConfigType, value: str) -> str: + # Resolved at codegen time; the cast picks set_effect(uint32_t) over the optional overload. + return f"static_cast({_resolve_effect_index(config, value)})" + + +_LIGHT_CONTROL_FIELDS = ( + automation.ApplyField(CONF_COLOR_MODE, "set_color_mode", ColorMode), + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + automation.ApplyField(CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + automation.ApplyField(CONF_RED, "set_red", cg.float_), + automation.ApplyField(CONF_GREEN, "set_green", cg.float_), + automation.ApplyField(CONF_BLUE, "set_blue", cg.float_), + automation.ApplyField(CONF_WHITE, "set_white", cg.float_), + automation.ApplyField(CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + automation.ApplyField(CONF_COLD_WHITE, "set_cold_white", cg.float_), + automation.ApplyField(CONF_WARM_WHITE, "set_warm_white", cg.float_), + automation.ApplyField( + CONF_EFFECT, "set_effect", cg.std_string, const_fn=_effect_index + ), ) -@automation.register_action( - "light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, synchronous=True + +automation.register_apply_action( + "light.turn_off", + LIGHT_TURN_OFF_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="make_call", ) -@automation.register_action( - "light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "light.turn_on", + LIGHT_TURN_ON_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", +) +automation.register_apply_action( + "light.control", + LIGHT_CONTROL_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", ) -async def light_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_COLOR_MODE, "set_color_mode", ColorMode), - (CONF_STATE, "set_state", cg.bool_), - (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), - (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), - (CONF_BRIGHTNESS, "set_brightness", cg.float_), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), - (CONF_RED, "set_red", cg.float_), - (CONF_GREEN, "set_green", cg.float_), - (CONF_BLUE, "set_blue", cg.float_), - (CONF_WHITE, "set_white", cg.float_), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), - (CONF_COLD_WHITE, "set_cold_white", cg.float_), - (CONF_WARM_WHITE, "set_warm_white", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if conf_key not in config: - continue - value = config[conf_key] - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - if CONF_EFFECT in config: - if isinstance(config[CONF_EFFECT], Lambda): - inner_lambda = await cg.process_lambda( - config[CONF_EFFECT], normalized_args, return_type=cg.std_string - ) - body_lines.append( - f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" - f"call.set_effect(parent->get_effect_index(" - f"__effect_s.c_str(), __effect_s.size())); }}" - ) - else: - # Cast disambiguates between set_effect(uint32_t) and - # set_effect(optional) when the literal is an int. - body_lines.append( - f"call.set_effect(static_cast({_resolve_effect_index(config)}));" - ) - - apply_args = [ - (LightState.operator("ptr"), "parent"), - (LightCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: @@ -385,7 +455,8 @@ LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA = cv.Schema( cv.Optional(CONF_BLUE): cv.templatable(cv.percentage), cv.Optional(CONF_WHITE): cv.templatable(cv.percentage), } -) +).extend(COLOR_SCHEMA) +LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA.add_extra(color_to_rgb) @automation.register_action( @@ -422,24 +493,15 @@ async def light_addressable_set_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( - "light.is_on", - LightIsOnCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(LightState), - } - ), +LIGHT_CONDITION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(LightState), + } ) -@automation.register_condition( - "light.is_off", - LightIsOffCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(LightState), - } - ), + +automation.register_apply_condition( + "light.is_on", LIGHT_CONDITION_SCHEMA, "current_values.is_on()" +) +automation.register_apply_condition( + "light.is_off", LIGHT_CONDITION_SCHEMA, "current_values.is_on() == false" ) -async def light_is_on_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 3ae15f9ee5d..b12ec189b9e 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry +from .automation import validate_light_state from .types import ( COLOR_MODES, AddressableColorWipeEffect, @@ -281,7 +282,7 @@ async def random_effect_to_code(config, effect_id): cv.ensure_list( cv.Schema( { - cv.Optional(CONF_STATE, default=True): cv.boolean, + cv.Optional(CONF_STATE, default=True): validate_light_state, cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, cv.Optional(CONF_COLOR_MODE): cv.enum( COLOR_MODES, upper=True, space="_" @@ -544,12 +545,18 @@ async def addressable_flicker_effect_to_code(config, effect_id): return var +# LightState stores the active effect index in a uint16_t +MAX_EFFECTS = 65535 + + def validate_effects(allowed_effects): @schema_extractor("effects") def validator(value): if value == SCHEMA_EXTRACT: return (allowed_effects, EFFECTS_REGISTRY) + if isinstance(value, list) and len(value) > MAX_EFFECTS: + raise cv.Invalid(f"A light supports at most {MAX_EFFECTS} effects") value = cv.validate_registry("effect", EFFECTS_REGISTRY)(value) errors = [] names = set() diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 4eb5208c96e..0295fb36b8c 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -5,6 +5,12 @@ namespace esphome::light { +/// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from +struct GammaTable { + uint16_t lut[256]; + uint16_t gamma_x100; +}; + /// Binary search a monotonically increasing uint16[256] PROGMEM table. /// Returns the largest index where table[index] <= target. inline uint8_t gamma_table_reverse_search(const uint16_t *table, uint16_t target) { diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 4251565e855..f540d2f31f1 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -193,6 +193,11 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); +#ifdef USE_LIGHT_RESUME_EFFECT + // Snapshot before the adjustments below add flags of their own + const bool plain_turn_on = this->has_state() && this->state_ && (this->flags_ & ~STATE_ONLY_FLAGS_MASK) == 0; +#endif // USE_LIGHT_RESUME_EFFECT + // Color mode check if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); @@ -334,6 +339,15 @@ LightColorValues LightCall::validate_() { // validate transition length/flash length/effect not used at the same time bool supports_transition = color_mode & ColorCapability::BRIGHTNESS; +#ifdef USE_LIGHT_RESUME_EFFECT + // A plain turn-on from off brings back the effect that was running when the light was turned off + if (this->parent_->resume_effect_ && plain_turn_on && !this->parent_->remote_values.is_on() && + this->parent_->previous_effect_index_ != 0) { + this->effect_ = this->parent_->previous_effect_index_; + this->set_flag_(FLAG_HAS_EFFECT); + } +#endif // USE_LIGHT_RESUME_EFFECT + // If effect is already active, remove effect start if (this->has_effect_() && this->effect_ == this->parent_->active_effect_index_) { this->clear_flag_(FLAG_HAS_EFFECT); @@ -376,6 +390,11 @@ LightColorValues LightCall::validate_() { // Reason: When user turns off the light in frontend, the effect should also stop bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { +#ifdef USE_LIGHT_RESUME_EFFECT + // Remember what was running, including no effect, when a lit light is explicitly turned off + if (this->parent_->resume_effect_ && explicit_turn_off_request && this->parent_->remote_values.is_on()) + this->parent_->previous_effect_index_ = this->parent_->active_effect_index_; +#endif // USE_LIGHT_RESUME_EFFECT if (this->has_effect_()) { log_invalid_parameter(name, LOG_STR("cannot start effect when turning off")); this->clear_flag_(FLAG_HAS_EFFECT); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index e3352de7272..c9f6af7c916 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -215,6 +215,8 @@ class LightCall { FLAG_SAVE = 1 << 15, }; static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 + // Flags a plain turn-on may carry; any other flag means the caller asked for something specific + static constexpr uint16_t STATE_ONLY_FLAGS_MASK = FLAG_HAS_STATE | FLAG_PUBLISH | FLAG_SAVE; inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index aaa1176f9f8..98df98ce5d6 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -32,7 +32,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { root[ESPHOME_F("effect_count")] = state.get_effect_count(); } - auto values = state.remote_values; + auto values = state.get_reported_values(); const auto color_mode = values.get_color_mode(); const auto *mode_str = get_color_mode_json_str(color_mode); @@ -131,6 +131,13 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO call.set_white(float(root[ESPHOME_F("white_value")]) / 255.0f); } + if (root[ESPHOME_F("white")].is()) { + // White stays full because ESPHome multiplies brightness and white + call.set_color_mode_if_supported(ColorMode::WHITE); + call.set_brightness(float(root[ESPHOME_F("white")]) / 255.0f); + call.set_white(1.0f); + } + if (root[ESPHOME_F("color_temp")].is()) { call.set_color_temperature(float(root[ESPHOME_F("color_temp")])); } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 82c00e23823..1b9a7f94c1b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -1,15 +1,34 @@ #include "light_state.h" #include "esp_color_correction.h" #include "esphome/core/defines.h" +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +#include "esphome/core/application.h" +#endif #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" #include "light_output.h" #include "transformers.h" +#include + namespace esphome::light { static const char *const TAG = "light"; +// Colour modes are bitmasks of capabilities. A mode the light doesn't support may be a bare set of +// required capabilities (see restore_state.py's colour mode inference): use the first supported +// mode that provides all of them, or leave it unchanged if there is none. +static ColorMode resolve_color_mode(const LightTraits &traits, ColorMode requested) { + if (requested == ColorMode::UNKNOWN || traits.supports_color_mode(requested)) + return requested; + auto wanted = static_cast(requested); + for (ColorMode mode : traits.get_supported_color_modes()) { + if ((static_cast(mode) & wanted) == wanted) + return mode; + } + return requested; +} + LightState::LightState(LightOutput *output) : output_(output) {} LightTraits LightState::get_traits() { return this->output_->get_traits(); } @@ -37,38 +56,14 @@ void LightState::setup() { auto call = this->make_call(); LightStateRTCState recovered{}; - if (this->initial_state_callback_) { - this->initial_state_callback_(recovered); - this->initial_state_callback_ = nullptr; // One-shot — no longer needed + bool restored = false; + if (this->save_enabled_) { + this->rtc_ = this->make_entity_preference(); + restored = this->rtc_.load(&recovered); } - switch (this->restore_mode_) { - case LIGHT_RESTORE_DEFAULT_OFF: - case LIGHT_RESTORE_DEFAULT_ON: - case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: - case LIGHT_RESTORE_INVERTED_DEFAULT_ON: - this->rtc_ = this->make_entity_preference(); - // Attempt to load from preferences, else fall back to default values - if (!this->rtc_.load(&recovered)) { - recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON); - } else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { - // Inverted restore state - recovered.state = !recovered.state; - } - break; - case LIGHT_RESTORE_AND_OFF: - case LIGHT_RESTORE_AND_ON: - this->rtc_ = this->make_entity_preference(); - this->rtc_.load(&recovered); - recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); - break; - case LIGHT_ALWAYS_OFF: - recovered.state = false; - break; - case LIGHT_ALWAYS_ON: - recovered.state = true; - break; + if (this->state_callback_) { + this->state_callback_(recovered, restored); + this->state_callback_ = nullptr; // One-shot — no longer needed } // A light coming up on boot must never end up on-but-invisible: if the resolved restore @@ -79,7 +74,7 @@ void LightState::setup() { recovered.brightness = 1.0f; } - call.set_color_mode_if_supported(recovered.color_mode); + call.set_color_mode_if_supported(resolve_color_mode(traits, recovered.color_mode)); call.set_state(recovered.state); call.set_brightness_if_supported(recovered.brightness); call.set_color_brightness_if_supported(recovered.color_brightness); @@ -101,10 +96,24 @@ void LightState::dump_config() { ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); auto traits = this->get_traits(); if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { +#ifdef USE_LIGHT_GAMMA_LUT + // Read the stored gamma * 100 directly so dump_config does not pull in get_gamma_correct() + const unsigned gamma_x100 = + this->gamma_table_ != nullptr ? progmem_read_uint16(&this->gamma_table_->gamma_x100) : 0; +#else + const unsigned gamma_x100 = 0; +#endif ESP_LOGCONFIG(TAG, " Default Transition Length: %.1fs\n" - " Gamma Correct: %.2f", - this->default_transition_length_ / 1e3f, this->gamma_correct_); + " Gamma Correct: %u.%02u", + this->default_transition_length_ / 1e3f, gamma_x100 / 100, gamma_x100 % 100); +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + // The define is build wide; only lights that set the option have an interval + if (this->transition_state_publish_interval_ != 0) { + ESP_LOGCONFIG(TAG, " Transition State Publish Interval: %" PRIu32 "ms", + this->transition_state_publish_interval_); + } +#endif } if (traits.supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) { ESP_LOGCONFIG(TAG, @@ -130,13 +139,30 @@ void LightState::loop() { this->next_write_ = true; } - if (this->transformer_->is_finished()) { + const bool finished = this->transformer_->is_finished(); +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + if (this->transition_publish_enabled_ && !finished) { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_transition_state_publish_ >= this->transition_state_publish_interval_) { + this->publish_state(); + this->last_transition_state_publish_ = now; + } + } +#endif + + if (finished) { // if the transition has written directly to the output, current_values is outdated, so update it this->current_values = this->transformer_->get_target_values(); - this->transformer_->stop(); this->is_transformer_active_ = false; this->transformer_ = nullptr; +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + if (this->transition_publish_enabled_) { + // Report the end state from remote_values; a flash's stop() left publishing to us + this->transition_publish_enabled_ = false; + this->publish_state(); + } +#endif if (this->target_state_reached_listeners_) { for (auto *listener : *this->target_state_reached_listeners_) { listener->on_light_target_state_reached(); @@ -279,6 +305,14 @@ void LightState::current_values_as_ct(float *color_temperature, float *white_bri *white_brightness = this->gamma_correct_lut(*white_brightness); } +float LightState::get_gamma_correct() const { +#ifdef USE_LIGHT_GAMMA_LUT + if (this->gamma_table_ != nullptr) + return progmem_read_uint16(&this->gamma_table_->gamma_x100) * 0.01f; +#endif // USE_LIGHT_GAMMA_LUT + return 0.0f; +} + #ifdef USE_LIGHT_GAMMA_LUT float LightState::gamma_correct_lut(float value) const { if (value <= 0.0f) @@ -290,10 +324,10 @@ float LightState::gamma_correct_lut(float value) const { float scaled = value * 255.0f; auto idx = static_cast(scaled); if (idx >= 255) - return progmem_read_uint16(&this->gamma_table_[255]) / 65535.0f; + return progmem_read_uint16(&this->gamma_table_->lut[255]) / 65535.0f; float frac = scaled - idx; - float a = progmem_read_uint16(&this->gamma_table_[idx]); - float b = progmem_read_uint16(&this->gamma_table_[idx + 1]); + float a = progmem_read_uint16(&this->gamma_table_->lut[idx]); + float b = progmem_read_uint16(&this->gamma_table_->lut[idx + 1]); return (a + frac * (b - a)) / 65535.0f; } float LightState::gamma_uncorrect_lut(float value) const { @@ -304,12 +338,12 @@ float LightState::gamma_uncorrect_lut(float value) const { if (this->gamma_table_ == nullptr) return value; uint16_t target = static_cast(value * 65535.0f); - uint8_t lo = gamma_table_reverse_search(this->gamma_table_, target); + uint8_t lo = gamma_table_reverse_search(this->gamma_table_->lut, target); if (lo >= 255) return 1.0f; // Interpolate between lo and lo+1 - uint16_t a = progmem_read_uint16(&this->gamma_table_[lo]); - uint16_t b = progmem_read_uint16(&this->gamma_table_[lo + 1]); + uint16_t a = progmem_read_uint16(&this->gamma_table_->lut[lo]); + uint16_t b = progmem_read_uint16(&this->gamma_table_->lut[lo + 1]); if (b == a) return lo / 255.0f; float frac = static_cast(target - a) / static_cast(b - a); @@ -318,11 +352,14 @@ float LightState::gamma_uncorrect_lut(float value) const { #endif // USE_LIGHT_GAMMA_LUT void LightState::start_effect_(uint32_t effect_index) { + // An external add_effects() can exceed the codegen cap; ignore an index the uint16_t can't hold + if (effect_index > std::numeric_limits::max()) + return; this->stop_effect_(); if (effect_index == 0) return; - this->active_effect_index_ = effect_index; + this->active_effect_index_ = static_cast(effect_index); auto *effect = this->get_active_effect_(); effect->start_internal(); // Enable loop while effect is active @@ -348,10 +385,7 @@ void LightState::stop_effect_() { void LightState::start_transition_(const LightColorValues &target, uint32_t length, bool set_remote_values) { this->transformer_ = this->output_->create_default_transition(); this->transformer_->setup(this->current_values, target, length); - - if (set_remote_values) { - this->remote_values = target; - } + this->set_transformer_remote_values_(target, set_remote_values); // Enable loop while transition is active this->enable_loop(); } @@ -365,10 +399,7 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b this->transformer_ = make_unique(*this); this->transformer_->setup(end_colors, target, length); - - if (set_remote_values) { - this->remote_values = target; - }; + this->set_transformer_remote_values_(target, set_remote_values); // Enable loop while flash is active this->enable_loop(); } @@ -376,6 +407,9 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b void LightState::set_immediately_(const LightColorValues &target, bool set_remote_values) { this->is_transformer_active_ = false; this->transformer_ = nullptr; +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + this->transition_publish_enabled_ = false; +#endif this->current_values = target; if (set_remote_values) { this->remote_values = target; @@ -391,18 +425,27 @@ void LightState::disable_loop_if_idle_() { } } +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +void LightState::set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values) { + this->transition_publish_enabled_ = set_remote_values && this->transition_state_publish_interval_ > 0; + if (this->transition_publish_enabled_) { + this->last_transition_state_publish_ = App.get_loop_component_start_time(); + } + if (set_remote_values) { + this->remote_values = target; + } +} +#endif + void LightState::save_remote_values_() { + if (!this->save_enabled_) + return; LightStateRTCState saved; saved.color_mode = this->remote_values.get_color_mode(); - switch (this->restore_mode_) { - case LIGHT_RESTORE_AND_OFF: - case LIGHT_RESTORE_AND_ON: - saved.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); - break; - default: - saved.state = this->remote_values.is_on(); - break; - } + // Always the real on/off status (RESTORE_AND_ON/OFF used to persist a hardcoded + // true/false here instead; harmless, since those modes force `state` again on + // every load regardless of what was saved -- see _legacy_restore_statements). + saved.state = this->remote_values.is_on(); saved.brightness = this->remote_values.get_brightness(); saved.color_brightness = this->remote_values.get_color_brightness(); saved.red = this->remote_values.get_red(); @@ -412,7 +455,7 @@ void LightState::save_remote_values_() { saved.color_temp = this->remote_values.get_color_temperature(); saved.cold_white = this->remote_values.get_cold_white(); saved.warm_white = this->remote_values.get_warm_white(); - saved.effect = this->active_effect_index_; + saved.effect = static_cast(this->active_effect_index_); // the saved layout stays uint32_t this->rtc_.save(&saved); } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368c..fcb2ca40c90 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/string_ref.h" #include "light_call.h" +#include "esp_color_correction.h" #include "light_color_values.h" #include "light_effect.h" #include "light_traits.h" @@ -44,33 +45,7 @@ class LightTargetStateReachedListener { virtual void on_light_target_state_reached() = 0; }; -enum LightRestoreMode : uint8_t { - LIGHT_RESTORE_DEFAULT_OFF, - LIGHT_RESTORE_DEFAULT_ON, - LIGHT_ALWAYS_OFF, - LIGHT_ALWAYS_ON, - LIGHT_RESTORE_INVERTED_DEFAULT_OFF, - LIGHT_RESTORE_INVERTED_DEFAULT_ON, - LIGHT_RESTORE_AND_OFF, - LIGHT_RESTORE_AND_ON, -}; - struct LightStateRTCState { - LightStateRTCState(ColorMode color_mode, bool state, float brightness, float color_brightness, float red, float green, - float blue, float white, float color_temp, float cold_white, float warm_white) - : brightness(brightness), - color_brightness(color_brightness), - red(red), - green(green), - blue(blue), - white(white), - color_temp(color_temp), - cold_white(cold_white), - warm_white(warm_white), - effect(0), - color_mode(color_mode), - state(state) {} - LightStateRTCState() = default; // Group 4-byte aligned members first float brightness{1.0f}; float color_brightness{1.0f}; @@ -100,6 +75,21 @@ class LightState : public EntityBase, public Component { LightCall turn_on(); LightCall turn_off(); LightCall toggle(); + + /// The values reported to the frontend: current_values while a light publishes intermediate + /// states on an interval, otherwise remote_values. Each interval sample is a publish_state(), + /// so on_state automations run on every sample as well. + const LightColorValues &get_reported_values() const { +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + if (this->transition_publish_enabled_) { + return this->current_values; + } +#endif + return this->remote_values; + } + + /// True from the call that starts a transition or flash until it reaches its target. + bool is_transitioning() const { return this->transformer_ != nullptr; } LightCall make_call(); // ========== INTERNAL METHODS ========== @@ -162,22 +152,42 @@ class LightState : public EntityBase, public Component { } uint32_t get_default_transition_length() const { return this->default_transition_length_; } - /// Set the flash transition length +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH + /// Set the flash transition length; only compiled in when a light configures one void set_flash_transition_length(uint32_t flash_transition_length) { this->flash_transition_length_ = flash_transition_length; } uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } +#else + // Remove before 2027.4.0 + ESPDEPRECATED("set_flash_transition_length() does nothing unless flash_transition_length is set in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_flash_transition_length(uint32_t flash_transition_length) {} + uint32_t get_flash_transition_length() const { return 0; } +#endif - /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } - float get_gamma_correct() const { return this->gamma_correct_; } + // Remove before 2027.4.0 + ESPDEPRECATED("set_gamma_correct() does nothing; gamma is fixed at build time by gamma_correct in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_gamma_correct(float gamma_correct) {} + /// The gamma correction factor, read from the entry after the gamma lookup table; 0 without one + float get_gamma_correct() const; + +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + void set_transition_state_publish_interval(uint32_t transition_state_publish_interval) { + this->transition_state_publish_interval_ = transition_state_publish_interval; + } + uint32_t get_transition_state_publish_interval() const { return this->transition_state_publish_interval_; } +#endif #ifdef USE_LIGHT_GAMMA_LUT - /// Set pre-computed gamma forward lookup table (256-entry uint16 PROGMEM array) - void set_gamma_table(const uint16_t *forward) { this->gamma_table_ = forward; } + /// Set the pre-computed PROGMEM gamma curve + void set_gamma_table(const GammaTable *table) { this->gamma_table_ = table; } - /// Get the forward gamma lookup table - const uint16_t *get_gamma_table() const { return this->gamma_table_; } + /// Get the forward gamma lookup table, 256 PROGMEM entries + const uint16_t *get_gamma_table() const { return this->gamma_table_ != nullptr ? this->gamma_table_->lut : nullptr; } /// Apply gamma correction using the pre-computed forward LUT float gamma_correct_lut(float value) const; @@ -189,12 +199,19 @@ class LightState : public EntityBase, public Component { float gamma_uncorrect_lut(float value) const { return value; } #endif // USE_LIGHT_GAMMA_LUT - /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + /// Set the callback that resolves the boot-time state, called once during setup then + /// cleared. `restored` is true only when a persisted state actually loaded, in which + /// case the state argument already holds the loaded values; otherwise it is freshly + /// default-constructed. Values live in flash as code. + void set_state_callback(void (*callback)(LightStateRTCState &, bool restored)) { this->state_callback_ = callback; } - /// Set a callback to populate the initial state defaults during setup. - /// The callback is called once, then cleared. Values live in flash as code. - void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } + /// Set whether this light persists its state to preferences at all. + void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; } + +#ifdef USE_LIGHT_RESUME_EFFECT + /// Set whether a plain turn-on restores the effect that was active when the light was turned off. + void set_resume_effect(bool resume_effect) { this->resume_effect_ = resume_effect; } +#endif // USE_LIGHT_RESUME_EFFECT /// Return whether the light has any effects that meet the trait requirements. bool supports_effects() const { return !this->effects_.empty(); } @@ -291,6 +308,7 @@ class LightState : public EntityBase, public Component { friend LightOutput; friend LightCall; friend class AddressableLight; + friend class LightFlashTransformer; /// Internal method to start an effect with the given index void start_effect_(uint32_t effect_index); @@ -307,6 +325,18 @@ class LightState : public EntityBase, public Component { /// Internal method to set the color values to target immediately (with no transition). void set_immediately_(const LightColorValues &target, bool set_remote_values); + /// Point remote_values at the new transformer's target and, when this light publishes + /// intermediate states on an interval, start the interval clock. +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + void set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values); +#else + void set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values) { + if (set_remote_values) { + this->remote_values = target; + } + } +#endif + /// Internal method to save the current remote_values to the preferences void save_remote_values_(); @@ -347,28 +377,44 @@ class LightState : public EntityBase, public Component { */ std::unique_ptr> target_state_reached_listeners_; - /// Callback to populate initial state defaults — called once during setup, then cleared. + /// Callback that resolves the boot-time state — called once during setup, then cleared. /// Values live in flash as function body; no per-instance data storage beyond this pointer. - void (*initial_state_callback_)(LightStateRTCState &){nullptr}; + void (*state_callback_)(LightStateRTCState &, bool restored){nullptr}; - /// Value for storing the index of the currently active effect. 0 if no effect is active - uint32_t active_effect_index_{}; /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; - /// Gamma correction factor for the light. - float gamma_correct_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py +#endif +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + uint32_t transition_state_publish_interval_{0}; + uint32_t last_transition_state_publish_{0}; +#endif #ifdef USE_LIGHT_GAMMA_LUT - const uint16_t *gamma_table_{nullptr}; + const GammaTable *gamma_table_{nullptr}; #endif // USE_LIGHT_GAMMA_LUT + /// 1-based index of the active effect, 0 if none; codegen caps effects at MAX_EFFECTS in effects.py + uint16_t active_effect_index_{}; +#ifdef USE_LIGHT_RESUME_EFFECT + /// The effect index that was active when the light was last turned off; shares the active index's word + uint16_t previous_effect_index_{0}; +#endif // USE_LIGHT_RESUME_EFFECT /// Whether the light value should be written in the next cycle. - bool next_write_{true}; + bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. - bool is_transformer_active_{false}; - /// Restore mode of the light. - LightRestoreMode restore_mode_; + bool is_transformer_active_ : 1 {false}; + /// Whether this light persists its state to preferences at all. + bool save_enabled_ : 1 {false}; +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + /// True while the active transformer publishes current_values on an interval from loop(). + bool transition_publish_enabled_ : 1 {false}; +#endif +#ifdef USE_LIGHT_RESUME_EFFECT + /// Whether a plain turn-on restores the effect that was active when the light was turned off. + bool resume_effect_ : 1 {false}; +#endif // USE_LIGHT_RESUME_EFFECT }; } // namespace esphome::light diff --git a/esphome/components/light/restore_state.py b/esphome/components/light/restore_state.py new file mode 100644 index 00000000000..712b4895478 --- /dev/null +++ b/esphome/components/light/restore_state.py @@ -0,0 +1,345 @@ +""" +Functions and classes to translate the `restore_mode:` and `restore_state:` config keys into +the single runtime state callback that `LightState` actually understands. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR_MODE, CONF_STATE +from esphome.core import Lambda +from esphome.cpp_generator import call_lambda +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType + +from .automation import LIGHT_STATE_FIELDS, validate_light_state +from .types import ColorMode, LightStateRTCState + +RESTORE_STATE_KEEP = "KEEP" +RESTORE_STATE_INVERT = "INVERT" +RESTORE_STATE_INITIAL = "INITIAL" +RESTORE_STATE_ALL = "ALL" +RESTORE_STATE_NONE = "NONE" + + +@dataclass(frozen=True) +class LegacyRestoreMode: + cold_boot_state: bool + restore_action: bool | str | None # None = no override, "INVERT", or force-to-bool + save_enabled: bool + + +LEGACY_RESTORE_MODES: dict[str, LegacyRestoreMode] = { + "RESTORE_DEFAULT_OFF": LegacyRestoreMode(False, None, True), + "RESTORE_DEFAULT_ON": LegacyRestoreMode(True, None, True), + "ALWAYS_OFF": LegacyRestoreMode(False, None, False), + "ALWAYS_ON": LegacyRestoreMode(True, None, False), + "RESTORE_INVERTED_DEFAULT_OFF": LegacyRestoreMode( + False, RESTORE_STATE_INVERT, True + ), + "RESTORE_INVERTED_DEFAULT_ON": LegacyRestoreMode(True, RESTORE_STATE_INVERT, True), + "RESTORE_AND_OFF": LegacyRestoreMode(False, False, True), + "RESTORE_AND_ON": LegacyRestoreMode(True, True, True), +} + +# (config key, LightStateRTCState member) for every field, and for every field but `state`. +_ALL_STATE_FIELDS: tuple[tuple[str, str], ...] = tuple( + (field.conf_key, field.member) for field in LIGHT_STATE_FIELDS +) +_STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple( + (conf_key, member) + for conf_key, member in _ALL_STATE_FIELDS + if conf_key != CONF_STATE +) +# Canonical struct-member order, used only to make generated code deterministic -- +# these are independent field assignments, so the actual order never affects behavior. +_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS) + +# A pending `s. = ;` statement, tagged with the member it writes. +StateStatement = tuple[str, str] + + +def _inferred_color_mode(initial_state_config: ConfigType | None) -> str | None: + """A C++ expression for the capabilities needed by the colour fields `initial_state:` + sets, or None if `color_mode:` is given or no colour field is set. + + Colour modes are bitmasks of capabilities, so the result is a bare capability set, + not necessarily a real mode; LightState::setup() resolves it to a mode the light + actually supports. + """ + if not initial_state_config or CONF_COLOR_MODE in initial_state_config: + return None + modes = sorted( + { + str(field.color_mode) + for field in LIGHT_STATE_FIELDS + if field.color_mode is not None + and initial_state_config.get(field.conf_key) is not None + } + ) + if not modes: + return None + mask = " | ".join(f"static_cast({mode})" for mode in modes) + return f"static_cast<{ColorMode}>({mask})" + + +def _partition_state_statements( + initial_statements: list[StateStatement], + restore_statements: list[StateStatement], + save_enabled: bool, +) -> list[str]: + """Split initial/restore statements into what must run unconditionally versus what + depends on `restored`, and render the resulting lambda body lines. + + Fields whose statement is identical in both branches (e.g. RESTORE_AND_ON's + cold-boot and restore-time statements are both "s.state = true;") are hoisted out + of the `restored` branch entirely, so only the fields that actually depend on + `restored` end up inside it -- down to no branch at all when every field overlaps. + A member appearing more than once in the same list keeps only its last statement + (a plain, side-effect-free assignment): matches sequential-execution semantics, + since an earlier write to the same member is always fully overwritten by a later + one in the original code this replaces. + + `save_enabled` is false exactly when `restore_statements` is empty and `restored` + is unconditionally false at the call site (nothing is ever loaded), so the + initial-only branch can skip its `if (!restored)` guard entirely. + """ + # dict() over (member, statement) pairs keeps the *last* entry per member. + initial_map = dict(initial_statements) + restore_map = dict(restore_statements) + # A member outside _MEMBER_ORDER would be silently skipped below instead of + # raising -- catch that here so a typo doesn't turn into wrong state on a device. + assert set(initial_map) <= set(_MEMBER_ORDER) + assert set(restore_map) <= set(_MEMBER_ORDER) + + common: list[str] = [] + only_initial: list[str] = [] + only_restore: list[str] = [] + for member in _MEMBER_ORDER: + initial_stmt = initial_map.get(member) + restore_stmt = restore_map.get(member) + if initial_stmt is not None and initial_stmt == restore_stmt: + common.append(initial_stmt) + continue + if initial_stmt is not None: + only_initial.append(initial_stmt) + if restore_stmt is not None: + only_restore.append(restore_stmt) + + body = common + if only_restore and only_initial: + body += ["if (restored) {", *only_restore, "} else {", *only_initial, "}"] + elif only_restore: + body += ["if (restored) {", *only_restore, "}"] + elif only_initial: + if save_enabled: + body += ["if (!restored) {", *only_initial, "}"] + else: + body += only_initial + return body + + +async def _build_state_lambda( + initial_statements: list[StateStatement], + restore_statements: list[StateStatement], + save_enabled: bool, +) -> Lambda | None: + """ + Combine the initial and restore statements into a single lambda that applies the + correct values to a `LightStateRTCState &s` depending on whether a persisted state + actually loaded. + """ + if not initial_statements and not restore_statements: + return None + body = _partition_state_statements( + initial_statements, restore_statements, save_enabled + ) + args = [(LightStateRTCState.operator("ref"), "s"), (cg.bool_, "restored")] + return await cg.process_lambda(Lambda("\n".join(body)), args, return_type=cg.void) + + +async def _process_value(value: Any, member: str) -> str: + if isinstance(value, Lambda): + return_type = cg.bool_ if member == CONF_STATE else cg.float_ + lamb = await cg.process_lambda(value, [], return_type=return_type) + return call_lambda(lamb) + return cg.safe_exp(value) + + +async def _initial_state_statements( + initial_state_config: ConfigType | None, +) -> list[StateStatement]: + """ + Create assignments for every field the user set in `initial_state:`, in canonical + struct-member order. A field given as `!lambda` is resolved and called immediately, + the same way `light.control`'s own field lambdas are. + """ + if not initial_state_config: + return [] + statements: list[StateStatement] = [] + for conf_key, member in _ALL_STATE_FIELDS: + if (value := initial_state_config.get(conf_key)) is None: + continue + statements.append( + (member, f"s.{member} = {await _process_value(value, member)};") + ) + if (inferred := _inferred_color_mode(initial_state_config)) is not None: + statements.append(("color_mode", f"s.color_mode = {inferred};")) + return statements + + +async def _resolve_initial_value( + conf_key: str, member: str, initial_state_config: ConfigType | None +) -> str: + """ + Return the C++ expression to use for a `restore_state:` field whose value is INITIAL + """ + if ( + initial_state_config is not None + and (value := initial_state_config.get(conf_key)) is not None + ): + return await _process_value(value, member) + if conf_key == CONF_COLOR_MODE and ( + inferred := _inferred_color_mode(initial_state_config) + ): + return inferred + return f"LightStateRTCState{{}}.{member}" + + +async def _restore_state_statements( + restore_state_config: ConfigType, initial_state_config: ConfigType | None +) -> list[StateStatement]: + """ + Create a list of statements to apply the user's `restore_state:` config + """ + statements: list[StateStatement] = [] + state = restore_state_config[CONF_STATE] + if state == RESTORE_STATE_INVERT: + statements.append(("state", "s.state = !s.state;")) + elif state == RESTORE_STATE_INITIAL: + expr = await _resolve_initial_value(CONF_STATE, "state", initial_state_config) + statements.append(("state", f"s.state = {expr};")) + elif state != RESTORE_STATE_KEEP: + statements.append(("state", f"s.state = {cg.safe_exp(state)};")) + for conf_key, member in _STATE_STRUCT_FIELDS: + value = restore_state_config[conf_key] + if value == RESTORE_STATE_INITIAL: + expr = await _resolve_initial_value(conf_key, member, initial_state_config) + elif value == RESTORE_STATE_KEEP: + continue + else: + expr = cg.safe_exp(value) + statements.append((member, f"s.{member} = {expr};")) + return statements + + +def _legacy_restore_statements(mode: LegacyRestoreMode) -> list[StateStatement]: + """ + Create a list of statements to apply the legacy restore_mode: behavior. + """ + if mode.restore_action is None: + return [] + if mode.restore_action == RESTORE_STATE_INVERT: + return [("state", "s.state = !s.state;")] + return [("state", f"s.state = {str(mode.restore_action).lower()};")] + + +def _legacy_cold_boot_statements( + mode: LegacyRestoreMode, initial_state_config: ConfigType | None +) -> list[StateStatement]: + """ + Create a list of statements to apply the legacy restore_mode: cold-boot behavior. + """ + existing_state = ( + initial_state_config.get(CONF_STATE) if initial_state_config else None + ) + if existing_state is None: + if not mode.cold_boot_state: + return [] # already matches LightStateRTCState's own default + elif existing_state == mode.cold_boot_state: + return [] # initial_state: already set exactly this value + return [("state", f"s.state = {str(mode.cold_boot_state).lower()};")] + + +def _initial_state_overridden_by_legacy_mode( + mode: LegacyRestoreMode, initial_state_config: ConfigType | None +) -> bool: + """ + Is the user-config `initial_state:` value for `state` overridden by the legacy mode? + """ + if initial_state_config is None or CONF_STATE not in initial_state_config: + return False + return initial_state_config[CONF_STATE] != mode.cold_boot_state + + +def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: + """ + Extend a validator to also accept the `KEEP`/`INITIAL` options + """ + + @schema_extractor("one_of") + def validate(value: Any) -> Any: + if value == SCHEMA_EXTRACT: + # Editor completion: the sentinels plus the wrapped validator's own values, + # if it is an enum. Numeric validators have none to offer. + try: + inner = tuple(validator(SCHEMA_EXTRACT)) + except cv.Invalid: + inner = () + return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner) + if isinstance(value, str): + upper = value.strip().upper() + if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL): + return upper + return validator(value) + + return validate + + +@schema_extractor("one_of") +def _validate_restore_state_state(value: Any) -> str | bool: + if value == SCHEMA_EXTRACT: + return ( + RESTORE_STATE_KEEP, + RESTORE_STATE_INVERT, + RESTORE_STATE_INITIAL, + *validate_light_state(SCHEMA_EXTRACT), + ) + if isinstance(value, str): + upper = value.strip().upper() + if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL): + return upper + return validate_light_state(value) + + +_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema( + { + cv.Optional(field.conf_key, default=RESTORE_STATE_KEEP): ( + _validate_restore_state_state + if field.conf_key == CONF_STATE + else _keep_or(field.validator) + ) + for field in LIGHT_STATE_FIELDS + } +) + + +@schema_extractor("schema") +def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str: + """ + The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`. + """ + if value == SCHEMA_EXTRACT: + # The `all`/`none` string shorthands have no representation in the extracted + # docs schema; only the per-field mapping form is walked here. + return _RESTORE_STATE_FIELDS_SCHEMA + if isinstance(value, str): + upper = value.strip().upper() + if upper == RESTORE_STATE_ALL: + value = {} + elif upper == RESTORE_STATE_NONE: + return RESTORE_STATE_NONE + return _RESTORE_STATE_FIELDS_SCHEMA(value) diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 34e192a0346..27f5c396dc8 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -105,6 +105,11 @@ class LightFlashTransformer : public LightTransformer { } this->state_.current_values = this->get_start_values(); this->state_.remote_values = this->get_start_values(); +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + // The light reports the end state itself once the transformer finishes + if (this->state_.transition_publish_enabled_) + return; +#endif this->state_.publish_state(); } diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 1778aa8410c..7a95920a740 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +GammaTable = light_ns.struct("GammaTable") LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") @@ -14,7 +15,6 @@ Color = cg.esphome_ns.class_("Color") LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") -LightCall = light_ns.class_("LightCall") # Addressable strips ChannelColors = light_ns.struct("ChannelColors") @@ -42,12 +42,9 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) -LightControlAction = light_ns.class_("LightControlAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) -LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) -LightIsOffCondition = light_ns.class_("LightIsOffCondition", automation.Condition) # Triggers LightTurnOnTrigger = light_ns.class_( diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 0f42083cb52..a1138c7de82 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,8 +11,6 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType @@ -24,7 +22,6 @@ lightwaverf_ns = cg.esphome_ns.namespace("lightwaverf") LIGHTWAVERFComponent = lightwaverf_ns.class_( "LightWaveRF", cg.Component, cg.PollingComponent ) -LightwaveRawAction = lightwaverf_ns.class_("SendRawAction", automation.Action) CONFIG_SCHEMA = cv.Schema( @@ -36,49 +33,36 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -LIGHTWAVE_SEND_SCHEMA = cv.Any( - cv.int_range(min=1), - cv.Schema( - { - cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent), - cv.Required(CONF_NAME): cv.string, - cv.Required(CONF_CODE): cv.All( - [cv.Any(cv.hex_uint8_t)], - cv.Length(min=10), - ), - cv.Optional(CONF_REPEAT, default=10): cv.int_, - cv.Optional(CONF_INVERTED, default=False): cv.boolean, - cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_, - } +LIGHTWAVE_SEND_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent), + cv.Required(CONF_NAME): cv.string, + cv.Required(CONF_CODE): cv.All( + [cv.Any(cv.hex_uint8_t)], + cv.Length(min=10), + ), + cv.Optional(CONF_REPEAT, default=10): cv.int_, + cv.Optional(CONF_INVERTED, default=False): cv.boolean, + cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_, + } +) + + +automation.register_apply_action( + "lightwaverf.send_raw", + LIGHTWAVE_SEND_SCHEMA, + automation.ApplyCall( + "send_rx({}, {}, {}, {})", + ( + (CONF_CODE, cg.std_vector.template(cg.uint8)), + (CONF_REPEAT, cg.uint8), + (CONF_INVERTED, cg.bool_), + (CONF_PULSE_LENGTH, cg.int_), + ), ), ) -@automation.register_action( - "lightwaverf.send_raw", - LightwaveRawAction, - LIGHTWAVE_SEND_SCHEMA, - synchronous=True, -) -async def send_raw_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.int_) - cg.add(var.set_repeat(template_)) - template_ = await cg.templatable(config[CONF_INVERTED], args, cg.int_) - cg.add(var.set_inverted(template_)) - template_ = await cg.templatable(config[CONF_PULSE_LENGTH], args, cg.int_) - cg.add(var.set_pulse_length(template_)) - cg.add(var.set_code(config[CONF_CODE])) - return var - - async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 36dac3c86fc..d14f57e31e9 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include @@ -37,28 +36,6 @@ class LightWaveRF final : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction final : public Action { - public: - SendRawAction(LightWaveRF *parent) : parent_(parent){}; - TEMPLATABLE_VALUE(int, repeat); - TEMPLATABLE_VALUE(int, inverted); - TEMPLATABLE_VALUE(int, pulse_length); - TEMPLATABLE_VALUE(std::vector, code); - void set_code(std::initializer_list data) { this->code_ = std::vector(data); } - - void play(const Ts &...x) { - int repeats = this->repeat_.value(x...); - int inverted = this->inverted_.value(x...); - int pulse_length = this->pulse_length_.value(x...); - std::vector msg = this->code_.value(x...); - - this->parent_->send_rx(msg, repeats, inverted, pulse_length); - } - - protected: - LightWaveRF *parent_; -}; - #endif } // namespace esphome::lightwaverf #endif diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 4bfaa93ab7a..69a01ae1acf 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -37,7 +37,6 @@ LN882HBLETracker = ln882h_ble_tracker_ns.class_( ) StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -103,9 +102,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "ln882h_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -113,17 +111,8 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h index 8b211384a0c..98e58200fd1 100644 --- a/esphome/components/ln882h_ble_tracker/automation.h +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a4a7b5237d1..aa5bec98d53 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -12,13 +12,13 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] @@ -29,12 +29,8 @@ Lock = lock_ns.class_("Lock", cg.EntityBase) LockPtr = Lock.operator("ptr") LockCall = lock_ns.class_("LockCall") -UnlockAction = lock_ns.class_("UnlockAction", automation.Action) -LockAction = lock_ns.class_("LockAction", automation.Action) -OpenAction = lock_ns.class_("OpenAction", automation.Action) LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) -LockCondition = lock_ns.class_("LockCondition", Condition) LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") @@ -135,45 +131,24 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA, synchronous=True +for _name, _call in ( + ("lock.unlock", "unlock()"), + ("lock.lock", "lock()"), + ("lock.open", "open()"), +): + automation.register_apply_action( + _name, LOCK_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +automation.register_apply_condition( + "lock.is_locked", LOCK_ACTION_SCHEMA, f"state == {LockState.LOCK_STATE_LOCKED}" ) -@automation.register_action( - "lock.lock", LockAction, LOCK_ACTION_SCHEMA, synchronous=True +automation.register_apply_condition( + "lock.is_unlocked", + LOCK_ACTION_SCHEMA, + f"state == {LockState.LOCK_STATE_UNLOCKED}", ) -@automation.register_action( - "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True -) -async def lock_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index ec6ead79f38..e578a056439 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,49 +6,6 @@ namespace esphome::lock { -template class LockAction final : public Action { - public: - explicit LockAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->lock(); } - - protected: - Lock *lock_; -}; - -template class UnlockAction final : public Action { - public: - explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->unlock(); } - - protected: - Lock *lock_; -}; - -template class OpenAction final : public Action { - public: - explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->open(); } - - protected: - Lock *lock_; -}; - -template class LockCondition final : public Condition { - public: - LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { - auto check_state = this->state_ ? LockState::LOCK_STATE_LOCKED : LockState::LOCK_STATE_UNLOCKED; - return this->parent_->state == check_state; - } - - protected: - Lock *parent_; - bool state_; -}; - /// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. template struct LockStateForwarder { diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 86a9cdd3fbb..b32d0da8e74 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -32,9 +32,6 @@ enum LockState : uint8_t { }; const LogString *lock_state_to_string(LockState state); -/// Maximum length of lock state string (including null terminator): "UNLOCKING" = 10 -static constexpr size_t LOCK_STATE_STR_SIZE = 10; - class LockTraits { public: LockTraits() = default; diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b030840..d6b9bca38ba 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,3 +1,4 @@ +import logging import re from typing import Any @@ -63,6 +64,8 @@ from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] logger_ns = cg.esphome_ns.namespace("logger") LOG_LEVELS = { @@ -105,6 +108,7 @@ DEFAULT = "DEFAULT" CONF_INITIAL_LEVEL = "initial_level" CONF_LOGGER_ID = "logger_id" +CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONF_RUNTIME_TAG_LEVELS = "runtime_tag_levels" CONF_TASK_LOG_BUFFER_SIZE = "task_log_buffer_size" CONF_WAIT_FOR_CDC = "wait_for_cdc" @@ -219,6 +223,18 @@ def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: return config +def warn_ram_log_strings(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH) is False: + _LOGGER.warning( + "'%s: false' is ignored and will be rejected in 2027.4.0. Log format strings " + "always stay in flash now; copying them into RAM gave no speed gain and the " + "lost RAM caused crashes. Remove the option", + CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH, + ) + return config + + def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") @@ -232,7 +248,6 @@ LoggerMessageTrigger = logger_ns.class_( ) -CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -332,6 +347,7 @@ CONFIG_SCHEMA = cv.All( validate_local_no_higher_than_global, validate_initial_no_higher_than_global, validate_wait_for_cdc, + warn_ram_log_strings, ) @@ -362,12 +378,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) @@ -449,9 +466,6 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_build_flag("-DCORE_DEBUG_LEVEL=5") if CORE.is_esp32 and is_at_least_very_verbose: cg.add_build_flag("-DENABLE_I2C_DEBUG_BUFFER") - if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH): - cg.add_build_flag("-DUSE_STORE_LOG_STR_IN_FLASH") - if CORE.is_esp32: if config[CONF_HARDWARE_UART] == USB_CDC: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True) @@ -490,6 +504,7 @@ async def _late_logger_init(config: ConfigType) -> None: zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False) zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True) if has_serial_logging: + zephyr_add_prj_conf("SERIAL", True) if config[CONF_HARDWARE_UART] == UART0: zephyr_add_overlay("""&uart0 { status = "okay";};""") if config[CONF_HARDWARE_UART] == UART1: diff --git a/esphome/components/logger/log_buffer.h b/esphome/components/logger/log_buffer.h index 067ce04114f..14121d3dd9d 100644 --- a/esphome/components/logger/log_buffer.h +++ b/esphome/components/logger/log_buffer.h @@ -105,9 +105,9 @@ struct LogBuffer { this->format_vsnprintf_(format, args); this->finalize_(); } -#ifdef USE_STORE_LOG_STR_IN_FLASH - void HOT format_body_P(PGM_P format, va_list args) { - this->format_vsnprintf_P_(format, args); +#ifdef USE_ESP8266 + void HOT format_body_p(PGM_P format, va_list args) { + this->format_vsnprintf_p_(format, args); this->finalize_(); } #endif @@ -158,8 +158,8 @@ struct LogBuffer { return; this->process_vsnprintf_result_(vsnprintf(this->current_(), this->remaining_(), format, args)); } -#ifdef USE_STORE_LOG_STR_IN_FLASH - void format_vsnprintf_P_(PGM_P format, va_list args) { +#ifdef USE_ESP8266 + void format_vsnprintf_p_(PGM_P format, va_list args) { if (this->full_()) return; this->process_vsnprintf_result_(vsnprintf_P(this->current_(), this->remaining_(), format, args)); diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index bfc005070e7..757ba6771e0 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -127,9 +127,8 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch } #endif // USE_ESP32 || USE_HOST || USE_LIBRETINY || USE_ZEPHYR -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Implementation for ESP8266 with flash string support. -// Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. +#ifdef USE_ESP8266 +// ESP8266 keeps log format strings in flash. // // This function handles format strings stored in flash memory (PROGMEM) to save RAM. // Uses vsnprintf_P to read the format string directly from flash without copying to RAM. @@ -141,7 +140,7 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args, nullptr); } -#endif // USE_STORE_LOG_STR_IN_FLASH +#endif // USE_ESP8266 inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7ec..6bdd5910f63 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -200,9 +200,9 @@ class Logger final : public Component { float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT -#ifdef USE_STORE_LOG_STR_IN_FLASH - void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, - va_list args); // NOLINT +#ifdef USE_ESP8266 + // NOLINTNEXTLINE(readability-identifier-naming) + void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args); #endif protected: @@ -244,14 +244,14 @@ class Logger final : public Component { buf.format_body(format, args); } -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 // Format a log message with flash string format and write it to a buffer with header, footer, and null terminator // ESP8266-only (single-task), thread_name is always nullptr - inline void HOT format_log_to_buffer_with_terminator_P_(uint8_t level, const char *tag, int line, + inline void HOT format_log_to_buffer_with_terminator_p_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args, LogBuffer &buf) { buf.write_header(level, tag, line, nullptr); - buf.format_body_P(reinterpret_cast(format), args); + buf.format_body_p(reinterpret_cast(format), args); } #endif @@ -283,9 +283,9 @@ class Logger final : public Component { FormatType format, va_list args, const char *thread_name) { RecursionGuard guard(recursion_guard); LogBuffer buf{this->tx_buffer_, ESPHOME_LOGGER_TX_BUFFER_SIZE}; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 if constexpr (std::is_same_v) { - this->format_log_to_buffer_with_terminator_P_(level, tag, line, format, args, buf); + this->format_log_to_buffer_with_terminator_p_(level, tag, line, format, args, buf); } else #endif { @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 05fc959ceb2..8579708559d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -3,8 +3,10 @@ #include "esphome/components/esp32/crash_handler.h" #include +#include #include +#include #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include @@ -15,8 +17,10 @@ #include #endif #endif - -#include "esp_idf_version.h" +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) +#include "esp_sleep.h" +#endif #include "freertos/FreeRTOS.h" #include @@ -76,12 +80,22 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { uart_config.parity = UART_PARITY_DISABLE; uart_config.stop_bits = UART_STOP_BITS_1; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; +#if SOC_UART_SUPPORT_XTAL_CLK + uart_config.source_clk = UART_SCLK_XTAL; +#else uart_config.source_clk = UART_SCLK_DEFAULT; +#endif uart_param_config(uart_num, &uart_config); // The logger only writes to UART, never reads, so use the minimum RX buffer. // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1; uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0); +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) + // Always flush before going to light sleep. Could be disabled for devices + // without TOP_PD or if source_clk = UART_SCLK_RTC + esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART); +#endif } void Logger::pre_setup() { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index b7884b702ba..69ef6167da5 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -54,6 +54,7 @@ void Logger::cdc_loop_() { #endif void Logger::pre_setup() { +#ifdef CONFIG_SERIAL if (this->baud_rate_ > 0) { static const struct device *uart_dev = nullptr; switch (this->uart_) { @@ -87,6 +88,7 @@ void Logger::pre_setup() { #endif } } +#endif global_logger = this; ESP_LOGI(TAG, "Log initialized"); #ifdef USE_LOGGER_EARLY_MESSAGE diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index c3ac90ad114..6c25729beec 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, @@ -28,7 +29,6 @@ CONF_UV_INDEX = "uv_index" CONF_UV = "uv" CONF_WINDOW_CORRECTION_FACTOR = "window_correction_factor" -UNIT_COUNTS = "#" UNIT_UVI = "UVI" LTR390GAIN = ltr390_ns.enum("LTR390GAIN") diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c2091a6336b..51378e4cddf 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -42,7 +43,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr501_ns = cg.esphome_ns.namespace("ltr501") @@ -229,29 +229,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if infrared_cnt_config := config.get(CONF_INFRARED_COUNTS): - sens = await sensor.new_sensor(infrared_cnt_config) - cg.add(var.set_infrared_counts_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_full_spectrum_counts_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) - - if prox_cnt_config := config.get(CONF_PS_COUNTS): - sens = await sensor.new_sensor(prox_cnt_config) - cg.add(var.set_proximity_counts_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_INFRARED_COUNTS, var.set_infrared_counts_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_full_spectrum_counts_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) + await sensors(CONF_PS_COUNTS, var.set_proximity_counts_sensor) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index af09282e2d9..223528581e9 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -41,7 +42,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr_als_ps_ns = cg.esphome_ns.namespace("ltr_als_ps") @@ -219,29 +219,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if infrared_cnt_config := config.get(CONF_INFRARED_COUNTS): - sens = await sensor.new_sensor(infrared_cnt_config) - cg.add(var.set_infrared_counts_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_full_spectrum_counts_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) - - if prox_cnt_config := config.get(CONF_PS_COUNTS): - sens = await sensor.new_sensor(prox_cnt_config) - cg.add(var.set_proximity_counts_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_INFRARED_COUNTS, var.set_infrared_counts_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_full_spectrum_counts_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) + await sensors(CONF_PS_COUNTS, var.set_proximity_counts_sensor) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2d2f1d6288b..ca9a317fec0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_GROUP, CONF_ID, @@ -60,7 +61,6 @@ from .defines import ( get_focused_widgets, get_lv_images_used, get_refreshed_widgets, - set_widgets_completed, ) from .encoders import ( ENCODERS_CONFIG, @@ -107,6 +107,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.keyboard import attach_textareas # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -169,11 +170,17 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - build_flags = ( - CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + esphome_config = CORE.config[CONF_ESPHOME] + # User build flags come from esphome->build_flags and from the deprecated + # esphome->platformio_options->build_flags (a string or a list). + # Remove before 2026.12.0 + + pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get( + CONF_BUILD_FLAGS, [] ) - if not isinstance(build_flags, list): - build_flags = [build_flags] + if not isinstance(pio_build_flags, list): + pio_build_flags = [pio_build_flags] + build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags] # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', # or multiple defines in one string. define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' @@ -231,6 +238,7 @@ def multi_conf_validate(configs: list[dict]): CONF_COLOR_DEPTH, CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, + df.CONF_DEBUG_OUTLINE, ): if base_config[item] != config[item]: raise cv.Invalid( @@ -386,6 +394,12 @@ async def to_code(configs): df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) cg.add(lvgl_static.esphome_lvgl_init()) default_group = get_default_group(config_0) + df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE] + + # Create theme lambdas before any widgets. + async with LvContext(): + for config in configs: + await theme_to_code(config) for config in configs: frac = config[CONF_BUFFER_SIZE] @@ -439,7 +453,6 @@ async def to_code(configs): await touchscreens_to_code(lv_component, config) await encoders_to_code(lv_component, config, default_group) await keypads_to_code(lv_component, config, default_group) - await theme_to_code(config) await gradients_to_code(config) await styles_to_code(config) await set_obj_properties(lv_scr_act, config) @@ -450,20 +463,14 @@ async def to_code(configs): await msgboxes_to_code(lv_component, config) await animations_to_code(config.get(CONF_ANIMATIONS, [])) - # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. - set_widgets_completed(True) async with LvContext(): - # Local import: lv_list imports meter, which imports obj_spec/set_obj_properties - # from this module's own namespace - a top-level import here would be circular. + # Local import to avoid circularity from .widgets.lv_list import finish_list_triggers - # Must run before generate_triggers(): that's what actually processes other - # widgets' on_click etc. automations, which can include lvgl.list.add/remove/ - # clear actions that fire a list's on_add/on_remove triggers - those need to - # already exist by then, not still be pending. await finish_list_triggers() await generate_triggers() await generate_align_tos(configs[0]) + await attach_textareas() for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) @@ -627,6 +634,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, + cv.Optional(df.CONF_DEBUG_OUTLINE, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 26bb433f874..1ef22f9b91d 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -4,6 +4,7 @@ #ifdef USE_LVGL_ANIMATION #include "lvgl_esphome.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::lvgl { @@ -100,6 +101,31 @@ class LvAnimationTimingEaseInOut : public LvAnimationTiming { float slope_; }; +class LvAnimationTimingEaseIn : public LvAnimationTiming { + public: + LvAnimationTimingEaseIn(float slope) : slope_(slope) {} + float map_progress(float value) override { + const float sqr = value * value; + return this->slope_ * sqr + (1.0f - this->slope_) * value; + } + + protected: + float slope_; +}; + +class LvAnimationTimingEaseOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + const float inv = 1.0f - value; + const float sqr = 1.0f - inv * inv; + return this->slope_ * sqr + (1.0f - this->slope_) * value; + } + + protected: + float slope_; +}; + template class LvAnimation : public Component { public: LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, @@ -110,17 +136,8 @@ template class LvAnimation : public C } void start() { - if (this->state_ > AnimationState::STOPPED) - this->stop(); - if (this->duration_ == 0) + if (!this->prepare_()) return; - // evaluate any lambdas - for (size_t i = 0; i != DATA_SIZE; i++) { - this->data_from_[i] = this->from_[i].value(); - this->data_to_[i] = this->to_[i].value(); - } - this->start_time_ = millis(); - this->state_ = AnimationState::STARTED; this->loop(); this->start_callback_.call(); } @@ -143,29 +160,26 @@ template class LvAnimation : public C if (this->state_ == AnimationState::STOPPED) return; uint32_t elapsed = millis() - this->start_time_; - float progress = static_cast(elapsed) / static_cast(this->duration_); + float progress = clamp_at_most(elapsed / static_cast(this->duration_), 1.0f); switch (this->state_) { case AnimationState::STARTED: if (elapsed < this->start_delay_) return; this->state_ = AnimationState::RUNNING; this->start_time_ = millis(); + elapsed = 0; progress = 0.0f; break; case AnimationState::RUNNING: - if (progress >= 1.0f) { - progress = 1.0f; - this->stop(); - if (this->loop_) - this->start(); - } break; default: return; } + // state here is RUNNING. for (auto *timing : this->timings_) { - progress = timing->map_progress(progress); + // avoid overshooting + progress = clamp_at_most(timing->map_progress(progress), 1.0f); } lv_coord_t data[DATA_SIZE]; for (size_t i = 0; i != DATA_SIZE; i++) { @@ -173,6 +187,12 @@ template class LvAnimation : public C roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); } this->update_callback_(data); + if (elapsed >= this->duration_) { + this->stop(); + // Restart without drawing the first frame now, so the final frame is not overwritten before it is shown + if (this->loop_ && this->prepare_()) + this->start_callback_.call(); + } } float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } @@ -187,6 +207,22 @@ template class LvAnimation : public C template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } protected: + // Arms a new run. Returns false if there is nothing to run. + bool prepare_() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return false; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + return true; + } + void (*const update_callback_)(const lv_coord_t *data); LazyCallbackManager start_callback_{}; LazyCallbackManager stop_callback_{}; diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index 95d45de5ea2..bab413f04d7 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -76,6 +76,14 @@ TIMING_SCHEMA = cv.maybe_simple_value( "ease_in_out", {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, ), + timing_class( + "ease_in", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, + ), + timing_class( + "ease_out", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, + ), timing_class( "gravity", { diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index a62f466413f..d797688a7e8 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -14,7 +14,7 @@ from esphome.const import ( CONF_TIMEOUT, ) from esphome.core import Lambda -from esphome.cpp_generator import TemplateArguments, get_variable +from esphome.cpp_generator import StaticCastExpression, TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( @@ -30,7 +30,6 @@ from .defines import ( CONF_SHOW_SNOW, CONF_TOP_LAYER, PARTS, - StaticCastExpression, add_warning, get_focused_widgets, get_options, @@ -79,7 +78,6 @@ from .widgets import ( get_screen_active, get_widgets, set_obj_properties, - wait_for_widgets, ) # Widgets that are used in a focused/refreshed action are tracked in @@ -128,15 +126,7 @@ async def action_to_code( action_id, template_arg, args, - config=None, ): - # Ensure all required ids have been processed, so our LambdaContext doesn't get context-switched. - if config: - for lamb in config.values(): - if isinstance(lamb, Lambda): - for id_ in lamb.requires_ids: - await get_variable(id_) - await wait_for_widgets() async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) @@ -146,7 +136,7 @@ async def action_to_code( async def update_to_code(config, action_id, template_arg, args): async def do_update(widget: Widget): await set_obj_properties(widget, config) - await widget.type.to_code(widget, config) + await widget.type.update_to_code(widget, config) if ( widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config @@ -154,9 +144,7 @@ async def update_to_code(config, action_id, template_arg, args): lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) widgets = await get_widgets(config[CONF_ID]) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) @automation.register_condition( @@ -508,9 +496,7 @@ async def obj_update_to_code(config, action_id, template_arg, args): await set_obj_properties(widget, config) widgets = await get_widgets(config[CONF_ID]) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) def validate_refresh_config(config): diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index aa68e764211..fc7317f8b97 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -9,7 +9,7 @@ from esphome.const import CONF_STATE from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t -from ..widgets import Widget, get_widgets, wait_for_widgets +from ..widgets import Widget, get_widgets STATE_PRESSED = "PRESSED" STATE_CHECKED = "CHECKED" @@ -33,7 +33,6 @@ async def to_code(config): widget = widget[0] assert isinstance(widget, Widget) state = await BS_STATE.process(config[CONF_STATE]) - await wait_for_widgets() is_pressed = str(state) == str(LV_STATE.PRESSED) test_expr = widget.is_pressed() if is_pressed else widget.is_checked() async with LambdaContext(EVENT_ARG) as test_ctx: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 1eee8041f9a..c019dff96e0 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,12 +10,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import ( - CallExpression, - LambdaExpression, - MockObj, - MockObjClass, -) +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -25,21 +20,40 @@ DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" KEY_ESPHOME_FONTS_USED = "esphome_fonts_used" KEY_FOCUSED_WIDGETS = "focused_widgets" +KEY_LIST_TRIGGERS = "list_triggers" KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" KEY_NAMED_STYLES = "named_styles" +KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" -KEY_THEME_WIDGET_MAP = "theme_widget_map" +KEY_THEME_STYLES = "theme_styles" KEY_UPDATED_WIDGETS = "updated_widgets" -KEY_WIDGET_MAP = "widget_map" -KEY_WIDGETS_COMPLETED = "widgets_completed" -KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +KEY_WIDGET_MAP = "widget_map" +KEY_WIDGET_THEME_STYLES = "widget_theme_styles" +KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count" + +# Colours for the debug outline, in (red, green, blue) order. They are picked to stay +# distinct from each other and to show up on both light and dark backgrounds. +DEBUG_OUTLINE_COLORS = ( + (255, 0, 0), + (0, 160, 0), + (0, 0, 255), + (255, 140, 0), + (200, 0, 200), + (0, 190, 190), + (160, 100, 0), + (255, 0, 120), + (110, 110, 110), + (140, 200, 0), + (0, 110, 255), + (130, 0, 255), +) # Initial set of LVGL features that are always enabled. _INITIAL_LV_USES = frozenset( @@ -107,6 +121,15 @@ def get_options() -> dict[str, Any]: return _get_data(KEY_OPTIONS, {}) +def next_debug_outline_color() -> tuple[int, int, int]: + """Return the next debug outline colour, cycling through the palette.""" + # A one-element list so that the count can be updated in place. + count = _get_data(KEY_DEBUG_OUTLINE_COUNT, [0]) + color = DEBUG_OUTLINE_COLORS[count[0] % len(DEBUG_OUTLINE_COLORS)] + count[0] += 1 + return color + + def get_defines() -> dict[str, str]: return _get_data(KEY_LV_DEFINES, {}) @@ -115,8 +138,14 @@ def get_updated_widgets() -> dict: return _get_data(KEY_UPDATED_WIDGETS, {}) -def get_theme_widget_map() -> dict[str, Any]: - return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_styles() -> dict[str, MockObj]: + """Get a map of already created theme style names to their corresponding style IDs.""" + return _get_data(KEY_THEME_STYLES, {}) + + +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]: + """Get the map of widget type names to the list of (style variable, part/state name)""" + return _get_data(KEY_WIDGET_THEME_STYLES, {}) def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: @@ -135,20 +164,6 @@ def get_widget_map() -> dict[str, Any]: return _get_data(KEY_WIDGET_MAP, {}) -def get_widgets_completed() -> bool: - # ``[value]`` rather than the bare value so that we can mutate the - # entry in place; ``CORE.data`` is reset for us between runs. - return _get_data(KEY_WIDGETS_COMPLETED, [False])[0] - - -def set_widgets_completed(value: bool) -> None: - _get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value - - -def is_widget_completed(name: ID) -> bool: - return name in get_widget_map() - - def get_focused_widgets() -> set: return _get_data(KEY_FOCUSED_WIDGETS, set()) @@ -157,15 +172,8 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) -class StaticCastExpression(Expression): - __slots__ = ("type", "exp") - - def __init__(self, type: Any, exp: SafeExpType): - self.type = str(type) - self.exp = cg.safe_exp(exp) - - def __str__(self): - return f"static_cast<{self.type}>({self.exp})" +def get_list_triggers() -> dict: + return _get_data(KEY_LIST_TRIGGERS, {}) def add_define(macro: str, value="1"): @@ -192,31 +200,6 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression) -> Expression: - """ - Given a lambda, either reduce to a simple expression or call it, possibly with parameters - from the surrounding context - :param lamb: - :return: - """ - expr = lamb.content.strip() - if expr.startswith("return") and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression - expr = cg.RawExpression(expr[6:-1].strip()) - # Don't cast if the return type is a class - if isinstance(lamb.return_type, MockObjClass): - return expr - return StaticCastExpression(lamb.return_type, expr) - # If lambda has parameters, call it with their names - # Parameter names come from hardcoded component code (like "x", "it", "event") - # not from user input, so they're safe to use directly - if lamb.parameters and lamb.parameters.parameters: - return CallExpression( - lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] - ) - return CallExpression(lamb) - - class LValidator: """ A validator for a particular type used in LVGL. Usable in configs as a validator, also @@ -721,11 +704,11 @@ CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" -CONF_COLOR_DEPTH = "color_depth" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" +CONF_DEBUG_OUTLINE = "debug_outline" CONF_DEFAULT_FONT = "default_font" CONF_DEFAULT_GROUP = "default_group" CONF_DIR = "dir" @@ -808,7 +791,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" @@ -884,7 +866,7 @@ LV_SCALE_MODE = LvConstant( DEFAULT_ESPHOME_FONT = "esphome_lv_default_font" -def join_enums(enums, prefix=""): +def join_enums(enums: tuple[str], prefix: str = "") -> MockObj: enums = list(enums) enums.sort() # If a prefix is provided, prepend each constant with the prefix, and assume that all the constants are within the @@ -894,6 +876,19 @@ def join_enums(enums, prefix=""): return literal("|".join(f"(int){e.upper()}" for e in enums)) +def get_part_state_selector(part: str, state: str) -> MockObj: + """Combine a part and state into a single selector value, e.g. LV_PART_KNOB | LV_STATE_PRESSED.""" + state = "LV_STATE_" + state.removeprefix("LV_STATE_").upper() + part = "LV_PART_" + part.removeprefix("LV_PART_").upper() + if state == "LV_STATE_DEFAULT": + return literal(part) + if part == "LV_PART_MAIN": + return literal(state) + return MockObj( + StaticCastExpression("lv_style_selector_t", literal(state)) + ) | MockObj(StaticCastExpression("lv_style_selector_t", literal(part))) + + # fmt: off LV_COLOR_FORMATS = ( "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", diff --git a/esphome/components/lvgl/light/__init__.py b/esphome/components/lvgl/light/__init__.py index dcdf67a5207..0a8720f5eee 100644 --- a/esphome/components/lvgl/light/__init__.py +++ b/esphome/components/lvgl/light/__init__.py @@ -7,7 +7,7 @@ from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID from ..defines import CONF_WIDGET from ..lvcode import LvContext from ..types import LvType, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets lv_led_t = LvType("lv_led_t") LVLight = lvgl_ns.class_("LVLight", LightOutput) @@ -26,6 +26,5 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LvContext() as ctx: ctx.add(var.set_obj(widget.obj)) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 42352b96023..961fa4079d7 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -4,6 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import image from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw +from esphome.components.const.css_colors import CSS_COLORS from esphome.components.font import Font from esphome.components.image import Image_ import esphome.config_validation as cv @@ -16,7 +17,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -33,9 +34,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, - StaticCastExpression, add_lv_use, - call_lambda, get_esphome_fonts_used, get_lv_fonts_used, get_lv_images_used, @@ -66,170 +65,19 @@ opacity = LValidator( animatable=True, ) -COLOR_NAMES = { - "aliceblue": 0xF0F8FF, - "antiquewhite": 0xFAEBD7, - "aqua": 0x00FFFF, - "aquamarine": 0x7FFFD4, - "azure": 0xF0FFFF, - "beige": 0xF5F5DC, - "bisque": 0xFFE4C4, - "black": 0x000000, - "blanchedalmond": 0xFFEBCD, - "blue": 0x0000FF, - "blueviolet": 0x8A2BE2, - "brown": 0xA52A2A, - "burlywood": 0xDEB887, - "cadetblue": 0x5F9EA0, - "chartreuse": 0x7FFF00, - "chocolate": 0xD2691E, - "coral": 0xFF7F50, - "cornflowerblue": 0x6495ED, - "cornsilk": 0xFFF8DC, - "crimson": 0xDC143C, - "cyan": 0x00FFFF, - "darkblue": 0x00008B, - "darkcyan": 0x008B8B, - "darkgoldenrod": 0xB8860B, - "darkgray": 0xA9A9A9, - "darkgreen": 0x006400, - "darkgrey": 0xA9A9A9, - "darkkhaki": 0xBDB76B, - "darkmagenta": 0x8B008B, - "darkolivegreen": 0x556B2F, - "darkorange": 0xFF8C00, - "darkorchid": 0x9932CC, - "darkred": 0x8B0000, - "darksalmon": 0xE9967A, - "darkseagreen": 0x8FBC8F, - "darkslateblue": 0x483D8B, - "darkslategray": 0x2F4F4F, - "darkslategrey": 0x2F4F4F, - "darkturquoise": 0x00CED1, - "darkviolet": 0x9400D3, - "deeppink": 0xFF1493, - "deepskyblue": 0x00BFFF, - "dimgray": 0x696969, - "dimgrey": 0x696969, - "dodgerblue": 0x1E90FF, - "firebrick": 0xB22222, - "floralwhite": 0xFFFAF0, - "forestgreen": 0x228B22, - "fuchsia": 0xFF00FF, - "gainsboro": 0xDCDCDC, - "ghostwhite": 0xF8F8FF, - "goldenrod": 0xDAA520, - "gold": 0xFFD700, - "gray": 0x808080, - "green": 0x008000, - "greenyellow": 0xADFF2F, - "grey": 0x808080, - "honeydew": 0xF0FFF0, - "hotpink": 0xFF69B4, - "indianred": 0xCD5C5C, - "indigo": 0x4B0082, - "ivory": 0xFFFFF0, - "khaki": 0xF0E68C, - "lavenderblush": 0xFFF0F5, - "lavender": 0xE6E6FA, - "lawngreen": 0x7CFC00, - "lemonchiffon": 0xFFFACD, - "lightblue": 0xADD8E6, - "lightcoral": 0xF08080, - "lightcyan": 0xE0FFFF, - "lightgoldenrodyellow": 0xFAFAD2, - "lightgray": 0xD3D3D3, - "lightgreen": 0x90EE90, - "lightgrey": 0xD3D3D3, - "lightpink": 0xFFB6C1, - "lightsalmon": 0xFFA07A, - "lightseagreen": 0x20B2AA, - "lightskyblue": 0x87CEFA, - "lightslategray": 0x778899, - "lightslategrey": 0x778899, - "lightsteelblue": 0xB0C4DE, - "lightyellow": 0xFFFFE0, - "lime": 0x00FF00, - "limegreen": 0x32CD32, - "linen": 0xFAF0E6, - "magenta": 0xFF00FF, - "maroon": 0x800000, - "mediumaquamarine": 0x66CDAA, - "mediumblue": 0x0000CD, - "mediumorchid": 0xBA55D3, - "mediumpurple": 0x9370DB, - "mediumseagreen": 0x3CB371, - "mediumslateblue": 0x7B68EE, - "mediumspringgreen": 0x00FA9A, - "mediumturquoise": 0x48D1CC, - "mediumvioletred": 0xC71585, - "midnightblue": 0x191970, - "mintcream": 0xF5FFFA, - "mistyrose": 0xFFE4E1, - "moccasin": 0xFFE4B5, - "navajowhite": 0xFFDEAD, - "navy": 0x000080, - "oldlace": 0xFDF5E6, - "olive": 0x808000, - "olivedrab": 0x6B8E23, - "orange": 0xFFA500, - "orangered": 0xFF4500, - "orchid": 0xDA70D6, - "palegoldenrod": 0xEEE8AA, - "palegreen": 0x98FB98, - "paleturquoise": 0xAFEEEE, - "palevioletred": 0xDB7093, - "papayawhip": 0xFFEFD5, - "peachpuff": 0xFFDAB9, - "peru": 0xCD853F, - "pink": 0xFFC0CB, - "plum": 0xDDA0DD, - "powderblue": 0xB0E0E6, - "purple": 0x800080, - "rebeccapurple": 0x663399, - "red": 0xFF0000, - "rosybrown": 0xBC8F8F, - "royalblue": 0x4169E1, - "saddlebrown": 0x8B4513, - "salmon": 0xFA8072, - "sandybrown": 0xF4A460, - "seagreen": 0x2E8B57, - "seashell": 0xFFF5EE, - "sienna": 0xA0522D, - "silver": 0xC0C0C0, - "skyblue": 0x87CEEB, - "slateblue": 0x6A5ACD, - "slategray": 0x708090, - "slategrey": 0x708090, - "snow": 0xFFFAFA, - "springgreen": 0x00FF7F, - "steelblue": 0x4682B4, - "tan": 0xD2B48C, - "teal": 0x008080, - "thistle": 0xD8BFD8, - "tomato": 0xFF6347, - "turquoise": 0x40E0D0, - "violet": 0xEE82EE, - "wheat": 0xF5DEB3, - "white": 0xFFFFFF, - "whitesmoke": 0xF5F5F5, - "yellow": 0xFFFF00, - "yellowgreen": 0x9ACD32, -} - @schema_extractor("one_of") def color(value): if value == SCHEMA_EXTRACT: return ["hex color value", "color ID"] - return cv.Any(cv.int_, cv.one_of(*COLOR_NAMES, lower=True), cv.use_id(ColorStruct))( + return cv.Any(cv.int_, cv.one_of(*CSS_COLORS, lower=True), cv.use_id(ColorStruct))( value ) def get_component_colors(value): - if isinstance(value, str) and value in COLOR_NAMES: - value = COLOR_NAMES[value] + if isinstance(value, str) and value in CSS_COLORS: + value = CSS_COLORS[value] if isinstance(value, int): return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): @@ -256,8 +104,8 @@ class LvColor(LValidator): ) def __getattr__(self, item): - if item in COLOR_NAMES: - return color_retmapper(COLOR_NAMES[item]) + if item in CSS_COLORS: + return color_retmapper(CSS_COLORS[item]) raise AttributeError(item) @@ -309,10 +157,12 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def scale_validator(value): - return cv.float_range(0.1, 10.0)(value) + return cv.float_range(0.0, 10.0)(value) -scale = LValidator(scale_validator, uint32, retmapper=lambda x: int(x * 256)) +scale = LValidator( + scale_validator, uint32, retmapper=lambda x: int(x * 256), animatable=True +) def angle(value): diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 850b63a26f4..d6d1aaa4144 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -1,5 +1,5 @@ import abc -from typing import TYPE_CHECKING +import contextvars from esphome import codegen as cg from esphome.config import Config @@ -65,15 +65,33 @@ class IndentedStatement(Statement): return result -class CodeContext(abc.ABC): +class _CodeContextMeta(abc.ABCMeta): + """ + Backs `CodeContext.code_context` with a contextvar instead of a plain class + attribute, so the FakeEventLoop's per-task context isolation (see coroutine.py) + keeps interleaved to_code() jobs from clobbering each other's current context. + """ + + _context_var: contextvars.ContextVar["CodeContext | None"] = contextvars.ContextVar( + "code_context", default=None + ) + + @property + def code_context(cls) -> "CodeContext | None": + return _CodeContextMeta._context_var.get() + + @code_context.setter + def code_context(cls, value: "CodeContext | None") -> None: + _CodeContextMeta._context_var.set(value) + + +class CodeContext(abc.ABC, metaclass=_CodeContextMeta): """ A class providing a context for code generation. Generated code will be added to the current context. A new context will stack on the current context, and restore it when done. Used with the `with` statement. """ - code_context = None - @abc.abstractmethod def add(self, expression: Expression | Statement): pass @@ -115,6 +133,9 @@ class CodeContext(abc.ABC): def indented_statement(self, stmt): return IndentedStatement(stmt, self.indent_level) + def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: + return [] + class MainContext(CodeContext): """ @@ -212,10 +233,6 @@ def get_lambda_context_args() -> list[tuple[SafeExpType, str]]: """ if CodeContext.code_context is None: return [] - if TYPE_CHECKING: - # CodeContext base class doesn't define get_automation_parameters(), - # but LambdaContext and LvContext (the concrete implementations) do. - assert isinstance(CodeContext.code_context, LambdaContext) return CodeContext.code_context.get_automation_parameters() diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 2c988473a93..90a770ac3b0 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -994,6 +994,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index be51963ba12..0d8d99ab218 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -22,7 +22,7 @@ from ..lvcode import ( ) from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA from ..types import LvNumber, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component) @@ -47,7 +47,6 @@ async def to_code(config): trigger = CONF_ON_RELEASE widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LambdaContext([], return_type=cg.float_) as value: value.add(ReturnStatement(widget.get_value())) async with LambdaContext([(cg.float_, "v")]) as control: diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bbc977dca51..cf766b96c86 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -292,8 +292,8 @@ BASE_PROPS = { "LV_TEXT_DECOR_", "NONE", "UNDERLINE", "STRIKETHROUGH" ).several_of, "text_font": lv_font, - "text_letter_space": lvalid.lv_positive_int, - "text_line_space": lvalid.lv_positive_int, + "text_letter_space": lvalid.lv_int, + "text_line_space": lvalid.lv_int, "text_opa": lvalid.opacity, "text_outline_stroke_color": lvalid.lv_color, "text_outline_stroke_opa": lvalid.opacity, @@ -670,11 +670,7 @@ def theme_update_schema(value: dict) -> dict: for w_name, style in validated.items(): for part, states in collect_parts(style).items(): for state, props in states.items(): - # collect_parts() unconditionally seeds a main/default entry - # even when nothing was set for it (e.g. `{pressed: {...}}` - # alone) -- skip combos with no properties so a request for - # one state doesn't also create an unused, empty main/default - # style that gets attached to every widget of this type. + # Skip states with no properties if not props: continue df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = ( diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index e69ea9771a0..c5ef98b05e5 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -5,7 +5,7 @@ from ..defines import CONF_TRIGGER, CONF_WIDGET from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA from ..types import LvNumber -from ..widgets import Widget, get_widgets, wait_for_widgets +from ..widgets import Widget, get_widgets CONFIG_SCHEMA = sensor_schema(Sensor).extend( { @@ -20,7 +20,6 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] assert isinstance(widget, Widget) - await wait_for_widgets() async with LambdaContext(EVENT_ARG) as lamb: lv_add(sensor.publish_state(widget.get_value())) async with LvContext(): diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index ad420283279..1fef37e2ee8 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -1,17 +1,22 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_DEFAULT, CONF_ID from esphome.core import ID +from esphome.cpp_generator import MockObj from .defines import ( CONF_STYLE_DEFINITIONS, CONF_THEME, + PARTS, + STATES, LValidator, add_lv_use, + get_part_state_selector, get_styles_used, + get_theme_styles, get_theme_update_requests, - get_theme_widget_map, + get_widget_theme_style_data, literal, ) from .lvcode import LambdaContext, lv @@ -23,7 +28,7 @@ from .schemas import ( theme_update_schema, ) from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts, wait_for_widgets +from .widgets import collect_parts def has_style_props(config) -> bool: @@ -89,7 +94,6 @@ async def styles_to_code(config): synchronous=True, ) async def style_update_to_code(config, action_id, template_arg, args): - await wait_for_widgets() style = await cg.get_variable(config[CONF_ID]) async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) @@ -101,41 +105,64 @@ async def style_update_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) +def _get_theme_style_name(w_name: str, part: str, state: str) -> str: + return f"_lv_theme_style_{w_name}_{part}_{state}" + + +def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: + """Return a list of (style variable, part/state name) for all theme styles used by the given widget type.""" + widget_styles = get_widget_theme_style_data() + if w_name in widget_styles: + return widget_styles[w_name] + theme_styles = get_theme_styles() + style_list = [] + for part in PARTS: + for state in STATES + (CONF_DEFAULT,): + style_name = _get_theme_style_name(w_name, part, state) + if style_name in theme_styles: + style_list.append( + (theme_styles[style_name], get_part_state_selector(part, state)) + ) + widget_styles[w_name] = style_list + return style_list + + async def theme_to_code(config): + """ + Convert theme to C++ code. May be called multiple times for different LVGL instances. + A style is created for each (widget type, part, state) combo declared in the `theme:` section of the config, + or requested by a `theme.update` action. + If a style is requested but not declared, it is created as an empty placeholder. + :param config: + :return: + """ theme = config.get(CONF_THEME) or {} requests = get_theme_update_requests() - # Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather - # than a set -- a set of strings/tuples iterates in an order that depends on - # per-process hash randomization, which would otherwise churn the order hidden - # style variables are declared in main.cpp between builds of the same config. widget_names = [ w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests ] if not widget_names: return add_lv_use(CONF_THEME) - theme_map = get_theme_widget_map() + style_map = get_theme_styles() for w_name in widget_names: declared_parts = collect_parts(theme[w_name]) if w_name in theme else {} parts = {part: dict(states) for part, states in declared_parts.items()} for part, state in requests.get(w_name, {}): parts.setdefault(part, {}).setdefault(state, {}) - widget_styles = theme_map.setdefault(w_name, {}) for part, states in parts.items(): - part_styles = widget_styles.setdefault(part, {}) declared_states = declared_parts.get(part, {}) for state, props in states.items(): - if state not in part_styles: - part_styles[state] = await create_style( - "_lv_theme_style_" + w_name + "_" + part + "_" + state, props - ) + style_name = _get_theme_style_name(w_name, part, state) + if style_name not in style_map: + style_map[style_name] = await create_style(style_name, props) elif state in declared_states: # A `theme.update` request for this combo (possibly from # another LVGL instance) already created the style as an # empty placeholder before this instance's real `theme:` # declaration was reached -- apply the real values now # instead of silently leaving it empty. - await style_set(part_styles[state], props) + await style_set(style_map[style_name], props) @automation.register_action( @@ -144,40 +171,25 @@ async def theme_to_code(config): theme_update_schema, synchronous=True, ) -async def theme_update_to_code(config, action_id, template_arg, args): - await wait_for_widgets() - theme_map = get_theme_widget_map() - # Invariant this relies on: theme_update_schema() records every (widget - # type, part, state) combo this action targets as a request during config - # validation (which completes for the whole config tree before any - # to_code runs), and theme_to_code() -- which runs for every LVGL - # instance before any action's own to_code -- materialises a style for - # each recorded request. If that handshake is ever broken by a future - # change, fail with a diagnosable message rather than a bare KeyError. - to_update = [] +async def theme_update_to_code(config, action_id, template_arg, args) -> MockObj: + # The theme_update_schema records the requested (widget type, part, state) combos in a global dict so that + # theme_to_code() can create the corresponding styles variables. Here we await get_variable(), which will + # context switch if required so theme_to_code() can run and create the style variable. + to_update: list[tuple] = [] for w_name, style in config.items(): for part, states in collect_parts(style).items(): for state, props in states.items(): - # collect_parts() unconditionally seeds an (empty) main/default - # entry even when this action didn't target it -- skip it, both - # because there's nothing to update and because - # theme_update_schema no longer pre-creates a placeholder style - # for combos with no properties. + # Skip states with no properties to set. if not props: continue - style_var = theme_map.get(w_name, {}).get(part, {}).get(state) - if style_var is None: - raise cv.Invalid( - f"No theme style exists for '{w_name}' {part}/{state}. " - "This is an internal error -- please report it." - ) + style_var = await cg.get_variable( + ID(_get_theme_style_name(w_name, part, state)) + ) to_update.append((style_var, props)) async with LambdaContext(parameters=args, where=action_id) as context: for style_var, props in to_update: await style_set(style_var, props) - # Refresh and redraw every widget using this style -- otherwise the - # updated properties would sit unused until something else happens - # to invalidate the affected widgets. + # Trigger a redraw for affected widgets. lv.obj_report_style_change(style_var) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index 509e4f42ad7..907665eca61 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -15,7 +15,7 @@ from ..lvcode import ( lvgl_static, ) from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets LVGLSwitch = lvgl_ns.class_("LVGLSwitch", Switch, Component) CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend( @@ -28,7 +28,6 @@ CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend( async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() switch_id = MockObj(config[CONF_ID], "->") v = literal("v") async with LambdaContext([(cg.bool_, "v")]) as control: diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index 61db5444e86..3d5e0305b98 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -14,7 +14,7 @@ from ..lvcode import ( lvgl_static, ) from ..types import LV_EVENT, LvText, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets LVGLText = lvgl_ns.class_("LVGLText", text.Text) @@ -29,7 +29,6 @@ async def to_code(config): textvar = await new_text(config) widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LambdaContext([(cg.std_string, "text_value")]) as control: await widget.set_property("text", "text_value.c_str()") lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) diff --git a/esphome/components/lvgl/text_sensor/__init__.py b/esphome/components/lvgl/text_sensor/__init__.py index c3306ad57a0..f0575fb1013 100644 --- a/esphome/components/lvgl/text_sensor/__init__.py +++ b/esphome/components/lvgl/text_sensor/__init__.py @@ -8,7 +8,7 @@ import esphome.config_validation as cv from ..defines import CONF_WIDGET from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LvText -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets CONFIG_SCHEMA = text_sensor_schema(TextSensor).extend( { @@ -21,7 +21,6 @@ async def to_code(config): sensor = await new_text_sensor(config) widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LambdaContext(EVENT_ARG) as pressed_ctx: pressed_ctx.add(sensor.publish_state(widget.get_value())) async with LvContext() as ctx: diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index c9099e3c3a5..d69e90056fd 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action -from esphome.config_validation import Invalid, Schema +from esphome.config_validation import Schema from esphome.const import ( CONF_DEFAULT, CONF_GROUP, @@ -16,11 +16,12 @@ from esphome.const import ( ) from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, call_lambda from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( + CONF_DEBUG_OUTLINE, CONF_FLEX_ALIGN_CROSS, CONF_FLEX_ALIGN_MAIN, CONF_FLEX_ALIGN_TRACK, @@ -42,13 +43,13 @@ from ..defines import ( STATES, LValidator, add_lv_use, - call_lambda, + get_options, + get_part_state_selector, get_styles_used, - get_theme_widget_map, get_widget_map, - get_widgets_completed, join_enums, literal, + next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( @@ -192,17 +193,26 @@ class WidgetType: w = Widget.create(wid, var, self, config) apply_theme_styles(w) await set_obj_properties(w, config) + apply_debug_outline(w) await add_widgets(w, config) await self.to_code(w, config) return w - async def to_code(self, w: "Widget", config: dict): + async def to_code(self, w: "Widget", config: dict) -> None: """ - Update a widget, also called when creating + Generate code for widget properties and actions. :param config: :return: """ + async def update_to_code(self, w: "Widget", config: dict) -> None: + """ + Update a widget. Defaults to calling to_code, but can be overridden + :param w: The widget to update + :param config: The configuration for the update + """ + await self.to_code(w, config) + async def obj_creator(self, parent: MockObj, config: dict): """ Create an instance of the widget type @@ -258,17 +268,25 @@ class WidgetType: def apply_theme_styles(w: "Widget") -> None: """Apply the current theme's styles for this widget's type""" - for part, states in get_theme_widget_map().get(w.type.name, {}).items(): - part = "LV_PART_" + part.upper() - for state, style in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) - w.add_style(style, lv_state) + from ..styles import get_widget_theme_styles + + for style, lv_state in get_widget_theme_styles(w.type.name): + w.add_style(style, lv_state) + + +def apply_debug_outline(w: "Widget") -> None: + """ + When `debug_outline` is set, outline this widget in the next palette colour. + An outline is drawn outside the widget's own box, so it doesn't take up layout + space and doesn't touch the widget's own `border_*` style, unlike a border. + """ + if not get_options().get(CONF_DEBUG_OUTLINE): + return + r, g, b = next_debug_outline_color() + w.set_style("outline_width", 1) + w.set_style("outline_pad", 0) + w.set_style("outline_color", f"lv_color_make({r}, {g}, {b})") + w.set_style("outline_opa", "LV_OPA_COVER") class Widget: @@ -324,8 +342,6 @@ class Widget: return lv_obj.remove_flag(self.obj, literal(flag)) def add_style(self, style_id, state=LV_STATE.DEFAULT): - if "|" in state: - state = f"(lv_state_t)({state})" lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( @@ -458,10 +474,6 @@ def get_widget_generator(wid): while True: if obj := widget_map.get(wid): return obj - if get_widgets_completed(): - raise Invalid( - f"Widget {wid} not found, yet all widgets should be defined by now" - ) yield @@ -471,19 +483,6 @@ async def get_widget_(wid): return await FakeAwaitable(get_widget_generator(wid)) -def widgets_wait_generator(): - while True: - if get_widgets_completed(): - return - yield - - -async def wait_for_widgets(): - if get_widgets_completed(): - return - await FakeAwaitable(widgets_wait_generator()) - - async def get_widgets(config: dict | list, id: str = CONF_ID) -> list[Widget]: if not config: return [] @@ -616,15 +615,8 @@ async def set_obj_properties(w: Widget, config): _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): - part = "LV_PART_" + part.upper() for state, props in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) + lv_state = get_part_state_selector(part, state) for style_id in props.get(CONF_STYLES, ()): w.add_style(style_id, lv_state) for prop, value in { diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index 0ad512cd8bb..306f6925018 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -15,9 +15,7 @@ lv_button_t = LvBoolean("lv_button_t") class ButtonType(WidgetType): def __init__(self): - super().__init__( - CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA, lv_name="btn" - ) + super().__init__(CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA) def validate(self, value): if CONF_TEXT in value: @@ -26,9 +24,6 @@ class ButtonType(WidgetType): add_lv_use("label") return value - def get_uses(self): - return ("btn",) - async def on_create(self, var: MockObj, config: dict): if CONF_TEXT in config: lv.label_create(var) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index 02dc9ed4ba8..854c5e3127d 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -188,7 +188,6 @@ class ButtonMatrixType(WidgetType): (CONF_MAIN, CONF_ITEMS), BUTTONMATRIX_SCHEMA, {}, - lv_name="buttonmatrix", ) async def to_code(self, w: Widget, config): @@ -272,5 +271,5 @@ async def button_update_to_code(config, action_id, template_arg, args): ) return await action_to_code( - widgets, do_button_update, action_id, template_arg, args, config + widgets, do_button_update, action_id, template_arg, args ) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4427a3b00eb..2137acb3f6a 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -141,7 +141,7 @@ async def canvas_fill(config, action_id, template_arg, args): async def do_fill(w: Widget): lv.canvas_fill_bg(w.obj, color, opa) - return await action_to_code(widget, do_fill, action_id, template_arg, args, config) + return await action_to_code(widget, do_fill, action_id, template_arg, args) @automation.register_action( @@ -176,9 +176,7 @@ async def canvas_set_pixel(config, action_id, template_arg, args): x, y = point lv.canvas_set_px(w.obj, x, y, color, opa) - return await action_to_code( - widget, do_set_pixels, action_id, template_arg, args, config - ) + return await action_to_code(widget, do_set_pixels, action_id, template_arg, args) DRAW_SCHEMA = { @@ -214,9 +212,7 @@ async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg await do_draw(addr(layer), x, y, dsc) lv.canvas_finish_layer(w.obj, addr(layer)) - return await action_to_code( - widget, action_func, action_id, template_arg, args, config - ) + return await action_to_code(widget, action_func, action_id, template_arg, args) RECT_PROPS = { diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index bcd2d2ae597..0356e66dac7 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -1,20 +1,13 @@ from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv from esphome.const import CONF_ITEMS, CONF_MODE -from esphome.core import CORE from esphome.cpp_types import std_string -from .. import LvContext -from ..defines import ( - CONF_MAIN, - KEYBOARD_MODES, - add_lv_use, - is_widget_completed, - literal, -) +from ..defines import CONF_MAIN, KEYBOARD_MODES, get_widget_map, literal from ..types import LvCompound, LvType from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX +from .label import CONF_LABEL from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -49,30 +42,32 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX + return ( + CONF_KEYBOARD, + CONF_TEXTAREA, + CONF_BUTTONMATRIX, + CONF_LABEL, + "KEY_LISTENER", + ) async def to_code(self, w: Widget, config: dict): - add_lv_use("KEY_LISTENER") if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) - if textarea := config.get(CONF_TEXTAREA): - if not is_widget_completed(textarea): - # Can only happen for an initial config, where the keyboard is configured before the - # textarea, so it's ok to always emit into the global context - async def add_textarea(): - async with LvContext(): - await w.set_property( - CONF_TEXTAREA, - (await get_widgets(config, CONF_TEXTAREA))[0].obj, - ) - CORE.add_job(add_textarea) - else: - # Handles updates in automations, and properly ordered initial config. Code is generated - # into the enclosing context (main or lambda) - await w.set_property( - CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj - ) + async def update_to_code(self, w: "Widget", config: dict) -> None: + await self.to_code(w, config) + if config.get(CONF_TEXTAREA): + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) + + +async def attach_textareas(): + for w in get_widget_map().values(): + if w.type == keyboard_spec and w.config.get(CONF_TEXTAREA): + await w.set_property( + CONF_TEXTAREA, (await get_widgets(w.config, CONF_TEXTAREA))[0].obj + ) keyboard_spec = KeyboardType() diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 83cbfb5ef99..f41cb8e4e96 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -1,4 +1,3 @@ -from collections.abc import Generator from dataclasses import dataclass, field from typing import Any @@ -15,8 +14,6 @@ from esphome.const import ( CONF_TEXT, CONF_TRIGGER_ID, ) -from esphome.core import CORE -from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -31,6 +28,7 @@ from ..defines import ( SWIPE_TRIGGERS, TYPE_FLEX, add_lv_use, + get_list_triggers, literal, ) from ..lv_validation import lv_int, lv_text, padding @@ -57,6 +55,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t from . import ( Widget, WidgetType, + apply_debug_outline, apply_theme_styles, collect_parts, get_widgets, @@ -64,6 +63,7 @@ from . import ( ) from .buttonmatrix import CONF_BUTTONMATRIX from .canvas import CONF_CANVAS +from .keyboard import CONF_KEYBOARD from .label import CONF_LABEL from .meter import CONF_METER from .tabview import CONF_TABVIEW @@ -74,8 +74,6 @@ CONF_WIDGET = "widget" CONF_ON_ADD = "on_add" CONF_ON_REMOVE = "on_remove" -DOMAIN = "lvgl_list" - lv_list_t = LvType("lv_list_t") @@ -86,76 +84,49 @@ class ListTriggers: def _get_list_triggers(list_id) -> ListTriggers: - """ - Trigger Pvariables built for a given list's `on_add`/`on_remove` config, indexed by the - list's own ID. - """ - triggers_by_list = CORE.data.setdefault(DOMAIN, {}) - return triggers_by_list.setdefault(list_id, ListTriggers()) + """Every on_add/on_remove automation config declared for a list, indexed by the list's own ID.""" + return get_list_triggers().get(list_id, ListTriggers()) -def _get_pending_list_triggers(list_id) -> ListTriggers: - """ - Same shape as _get_list_triggers(), but holding raw on_add/on_remove automation - configs, not yet built. - """ - pending_by_list = CORE.data.setdefault(DOMAIN + "_pending", {}) - return pending_by_list.setdefault(list_id, ListTriggers()) - - -def _list_triggers_completed_flag() -> list[bool]: - return CORE.data.setdefault(DOMAIN + "_completed", [False]) - - -def _list_triggers_completed_generator() -> Generator[None, None, None]: - while True: - if _list_triggers_completed_flag()[0]: - return - yield - - -async def _wait_list_triggers_completed() -> None: - """Waits until finish_list_triggers() has built every list's on_add/on_remove automations.""" - if _list_triggers_completed_flag()[0]: - return - await FakeAwaitable(_list_triggers_completed_generator()) +def _declare_list_triggers(list_id) -> ListTriggers: + """Get-or-create a list's trigger record, for ListType.on_create() to populate.""" + return get_list_triggers().setdefault(list_id, ListTriggers()) async def finish_list_triggers() -> None: """ - Builds every list's on_add/on_remove automations, collected by ListType.to_code() - instead of being built there directly. Must run after set_widgets_completed(True). + Builds every list's on_add/on_remove automations, collected by ListType.on_create() + instead of being built there directly. """ - for list_id, pending in CORE.data.get(DOMAIN + "_pending", {}).items(): - triggers = _get_list_triggers(list_id) - for conf in pending.on_add: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) - triggers.on_add.append(trigger) - for conf in pending.on_remove: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) - triggers.on_remove.append(trigger) - _list_triggers_completed_flag()[0] = True + # Avoid circular references by creating the trigger Pvariables first + built_triggers = [ + (cg.new_Pvariable(conf[CONF_TRIGGER_ID]), conf) + for triggers in get_list_triggers().values() + for conf in triggers.on_add + triggers.on_remove + ] + for trigger, conf in built_triggers: + await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) -def _fire_index_triggers(triggers: list, index) -> None: - for trigger in triggers: +async def _fire_index_triggers(confs: list, index) -> None: + for conf in confs: + # finish_list_triggers() may not have built this trigger yet if it runs as + # part of a different component's own to_code() job than this one - + # get_variable() blocks until it does, regardless of scheduling order. + trigger = await cg.get_variable(conf[CONF_TRIGGER_ID]) lv_add(trigger.trigger(index)) async def _fire_on_add(list_id, list_obj, entry_obj) -> None: - await _wait_list_triggers_completed() - triggers = _get_list_triggers(list_id).on_add - if not triggers: + confs = _get_list_triggers(list_id).on_add + if not confs: return index = cg.RawExpression(f"lvgl::lv_list_get_row_index({list_obj}, {entry_obj})") - _fire_index_triggers(triggers, index) + await _fire_index_triggers(confs, index) async def _fire_on_remove(list_id, index) -> None: - await _wait_list_triggers_completed() - _fire_index_triggers(_get_list_triggers(list_id).on_remove, index) + await _fire_index_triggers(_get_list_triggers(list_id).on_remove, index) LIST_SCHEMA = cv.Schema( @@ -199,14 +170,15 @@ class ListType(WidgetType): def get_uses(self): return TYPE_FLEX, CONF_LABEL, CONF_BUTTON - async def to_code(self, w: Widget, config: dict): + async def on_create(self, var: MockObj, config: dict) -> None: + # Runs before Widget.create() registers this list on_add = config.get(CONF_ON_ADD, ()) on_remove = config.get(CONF_ON_REMOVE, ()) if not on_add and not on_remove: return - pending = _get_pending_list_triggers(w.config[CONF_ID]) - pending.on_add.extend(on_add) - pending.on_remove.extend(on_remove) + triggers = _declare_list_triggers(config[CONF_ID]) + triggers.on_add.extend(on_add) + triggers.on_remove.extend(on_remove) list_spec = ListType() @@ -237,17 +209,19 @@ async def list_add_text_to_code(config, action_id, template_arg, args): lv.obj_move_to_index(entry, await lv_int.process(idx)) await _fire_on_add(config[CONF_ID], w.obj, entry) - return await action_to_code( - widgets, do_add_text, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_add_text, action_id, template_arg, args) +# These types of widgets are not compatible with lvgl.list.add, some since they manage their own children in a way +# that can't be rebuilt on every call, others are just Too Hard. _DYNAMIC_WIDGET_UNSUPPORTED = ( CONF_BUTTONMATRIX, + CONF_CANVAS, + CONF_KEYBOARD, + CONF_LIST, + CONF_METER, CONF_TABVIEW, CONF_TILEVIEW, - CONF_METER, - CONF_CANVAS, ) @@ -385,7 +359,7 @@ async def list_add_to_code(config, action_id, template_arg, args): index=index, ) - return await action_to_code(widgets, do_add, action_id, template_arg, args, config) + return await action_to_code(widgets, do_add, action_id, template_arg, args) async def _build_dynamic_widget( @@ -443,6 +417,7 @@ async def _finish_dynamic_widget( await w.type.on_create(w.obj, w_conf) apply_theme_styles(w) await set_obj_properties(w, w_conf) + apply_debug_outline(w) await w.type.to_code(w, w_conf) await _wire_dynamic_triggers(w, w_conf) for child in w_conf.get(CONF_WIDGETS, ()): @@ -523,9 +498,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): # Recursively destroys the whole subtree lv.obj_del(child) - return await action_to_code( - widgets, do_remove, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_remove, action_id, template_arg, args) @automation.register_action( @@ -538,16 +511,13 @@ async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) async def do_clear(w: Widget): - await _wait_list_triggers_completed() - triggers = _get_list_triggers(config[CONF_ID]).on_remove - if triggers: + confs = _get_list_triggers(config[CONF_ID]).on_remove + if confs: # Fire on_remove for every entry, newest to oldest, before wiping them all out, # so on_remove's semantics ("an entry left the list") hold with LvCountdown("list_index", lv_expr.obj_get_child_count(w.obj)) as index: - _fire_index_triggers(triggers, index) + await _fire_index_triggers(confs, index) # lv_obj_clean recursively destroys every child's whole subtree lv.obj_clean(w.obj) - return await action_to_code( - widgets, do_clear, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_clear, action_id, template_arg, args) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 166e88f382a..ba322881289 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -612,9 +612,7 @@ async def indicator_update_to_code(config, action_id, template_arg, args): async def set_value(w: Widget): await set_indicator_values(w.parent, w, config) - return await action_to_code( - widget, set_value, action_id, template_arg, args, config - ) + return await action_to_code(widget, set_value, action_id, template_arg, args) async def set_indicator_values(scale: MockObj, indicator: Widget, config): diff --git a/esphome/components/lvgl/widgets/page.py b/esphome/components/lvgl/widgets/page.py index 7e75ab6a2da..9dc9123ac9e 100644 --- a/esphome/components/lvgl/widgets/page.py +++ b/esphome/components/lvgl/widgets/page.py @@ -26,14 +26,7 @@ from ..lvcode import ( ) from ..schemas import LVGL_SCHEMA from ..types import LvglAction, LvglCondition, lv_page_t -from . import ( - Widget, - WidgetType, - add_widgets, - get_widgets, - set_obj_properties, - wait_for_widgets, -) +from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties CONF_ON_LOAD = "on_load" CONF_ON_UNLOAD = "on_unload" @@ -107,7 +100,6 @@ async def page_next_to_code(config, action_id, template_arg, args): ), ) async def page_is_showing_to_code(config, condition_id, template_arg, args): - await wait_for_widgets() page = await cg.get_variable(config[CONF_ID]) async with LambdaContext( [(lv_page_t.operator("ptr"), PAGE_ARG)], return_type=cg.bool_ diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index df76ab6bb0d..59af9168aad 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -10,6 +10,7 @@ from ..types import lv_obj_t from . import Widget, WidgetType from .canvas import CONF_CANVAS from .img import CONF_IMAGE +from .label import CONF_LABEL CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" @@ -41,7 +42,7 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return CONF_CANVAS, CONF_IMAGE + return CONF_CANVAS, CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): await w.set_property( diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py index f000ea18466..019c6d4ecaf 100644 --- a/esphome/components/lvgl/widgets/table.py +++ b/esphome/components/lvgl/widgets/table.py @@ -274,6 +274,4 @@ async def table_cell_update_to_code( ) await set_cell_ctrl(w, row, column, config) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index ee252ecf0b2..77c88c48ff8 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -28,6 +28,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec +from .label import CONF_LABEL from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -74,7 +75,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON + return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL async def to_code(self, w: Widget, config: dict): await w.set_property( diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h deleted file mode 100644 index 6b19e5bd5e9..00000000000 --- a/esphome/components/max17043/automation.h +++ /dev/null @@ -1,18 +0,0 @@ - -#pragma once -#include "esphome/core/automation.h" -#include "max17043.h" - -namespace esphome::max17043 { - -template class SleepAction final : public Action { - public: - explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} - - void play(const Ts &...x) override { this->max17043_->sleep_mode(); } - - protected: - MAX17043Component *max17043_; -}; - -} // namespace esphome::max17043 diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 67fb8aa5b7b..0b72955c74e 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,8 +14,6 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -25,9 +23,6 @@ MAX17043Component = max17043_ns.class_( "MAX17043Component", cg.PollingComponent, i2c.I2CDevice ) -# Actions -SleepAction = max17043_ns.class_("SleepAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -74,14 +69,6 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "max17043.sleep_mode", MAX17043_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()") ) -async def max17043_sleep_mode_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 6b8bdc8de5e..731f5840563 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -134,6 +134,4 @@ void MAX44009Sensor::write_(uint8_t reg, uint8_t value) { } } -void MAX44009Sensor::set_mode(MAX44009Mode mode) { this->mode_ = mode; } - } // namespace esphome::max44009 diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index b62aed7a567..5eb1555350a 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -16,7 +16,7 @@ class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, pub void setup() override; void dump_config() override; void update() override; - void set_mode(MAX44009Mode mode); + void set_mode(MAX44009Mode mode) { this->mode_ = mode; } bool set_continuous_mode(); bool set_low_power_mode(); diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 5e45d718999..557f497e1ab 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,8 +11,7 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -29,10 +28,6 @@ max6956_ns = cg.esphome_ns.namespace("max6956") MAX6956 = max6956_ns.class_("MAX6956", cg.Component, i2c.I2CDevice) MAX6956GPIOPin = max6956_ns.class_("MAX6956GPIOPin", cg.GPIOPin) -# Actions -SetCurrentGlobalAction = max6956_ns.class_("SetCurrentGlobalAction", automation.Action) -SetCurrentModeAction = max6956_ns.class_("SetCurrentModeAction", automation.Action) - MAX6956_CURRENTMODE = max6956_ns.enum("MAX6956CURRENTMODE") CURRENT_MODES = { "global": MAX6956_CURRENTMODE.GLOBAL, @@ -103,9 +98,8 @@ async def max6956_pin_to_code(config: ConfigType) -> MockObj: return var -@automation.register_action( +automation.register_apply_action( "max6956.set_brightness_global", - SetCurrentGlobalAction, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(MAX6956), @@ -115,24 +109,12 @@ async def max6956_pin_to_code(config: ConfigType) -> MockObj: }, key=CONF_BRIGHTNESS_GLOBAL, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS_GLOBAL, "set_brightness_global", cg.uint8), + automation.ApplyCall("write_brightness_global()"), ) -async def max6956_set_brightness_global_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) - cg.add(var.set_brightness_global(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "max6956.set_brightness_mode", - SetCurrentModeAction, cv.maybe_simple_value( { cv.Required(CONF_ID): cv.use_id(MAX6956), @@ -142,18 +124,8 @@ async def max6956_set_brightness_global_to_code( }, key=CONF_BRIGHTNESS_MODE, ), - synchronous=True, + automation.ApplyField( + CONF_BRIGHTNESS_MODE, "set_brightness_mode", MAX6956_CURRENTMODE + ), + automation.ApplyCall("write_brightness_mode()"), ) -async def max6956_set_brightness_mode_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_BRIGHTNESS_MODE], args, MAX6956_CURRENTMODE - ) - cg.add(var.set_brightness_mode(template_)) - return var diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h deleted file mode 100644 index f1db2e32400..00000000000 --- a/esphome/components/max6956/automation.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/max6956/max6956.h" - -namespace esphome::max6956 { - -template class SetCurrentGlobalAction final : public Action { - public: - SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} - - TEMPLATABLE_VALUE(uint8_t, brightness_global) - - void play(const Ts &...x) override { - this->max6956_->set_brightness_global(this->brightness_global_.value(x...)); - this->max6956_->write_brightness_global(); - } - - protected: - MAX6956 *max6956_; -}; - -template class SetCurrentModeAction final : public Action { - public: - SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} - - TEMPLATABLE_VALUE(max6956::MAX6956CURRENTMODE, brightness_mode) - - void play(const Ts &...x) override { - this->max6956_->set_brightness_mode(this->brightness_mode_.value(x...)); - this->max6956_->write_brightness_mode(); - } - - protected: - MAX6956 *max6956_; -}; -} // namespace esphome::max6956 diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h deleted file mode 100644 index f06dfd5087a..00000000000 --- a/esphome/components/max7219digit/automation.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -#include "max7219digit.h" - -namespace esphome::max7219digit { - -template class DisplayInvertAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->invert_on_off(state); - } -}; - -template class DisplayVisibilityAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->turn_on_off(state); - } -}; - -template class DisplayReverseAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->set_reverse(state); - } -}; - -template class DisplayIntensityAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, state) - - void play(const Ts &...x) override { - uint8_t state = this->state_.value(x...); - this->parent_->set_intensity(state); - } -}; - -} // namespace esphome::max7219digit diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 54711263dda..7974ef20202 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,8 +10,6 @@ from esphome.const import ( CONF_NUM_CHIPS, CONF_STATE, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@rspaargaren"] @@ -112,14 +110,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -DisplayInvertAction = max7219_ns.class_("DisplayInvertAction", automation.Action) -DisplayVisibilityAction = max7219_ns.class_( - "DisplayVisibilityAction", automation.Action -) -DisplayReverseAction = max7219_ns.class_("DisplayReverseAction", automation.Action) -DisplayIntensityAction = max7219_ns.class_("DisplayIntensityAction", automation.Action) - - MAX7219_OFF_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(MAX7219Component), @@ -135,79 +125,17 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "max7219digit.invert_off", - DisplayInvertAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.invert_on", - DisplayInvertAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -async def max7219digit_invert_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]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "max7219digit.turn_off", - DisplayVisibilityAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.turn_on", - DisplayVisibilityAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -async def max7219digit_visible_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]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "max7219digit.reverse_off", - DisplayReverseAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.reverse_on", - DisplayReverseAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -async def max7219digit_reverse_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]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var +for _name, _schema, _method in ( + ("max7219digit.invert_off", MAX7219_OFF_ACTION_SCHEMA, "invert_on_off"), + ("max7219digit.invert_on", MAX7219_ON_ACTION_SCHEMA, "invert_on_off"), + ("max7219digit.turn_off", MAX7219_OFF_ACTION_SCHEMA, "turn_on_off"), + ("max7219digit.turn_on", MAX7219_ON_ACTION_SCHEMA, "turn_on_off"), + ("max7219digit.reverse_off", MAX7219_OFF_ACTION_SCHEMA, "set_reverse"), + ("max7219digit.reverse_on", MAX7219_ON_ACTION_SCHEMA, "set_reverse"), +): + automation.register_apply_action( + _name, _schema, automation.ApplyField(CONF_STATE, _method, cg.bool_) + ) MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( @@ -221,20 +149,8 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "max7219digit.intensity", - DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_INTENSITY, "set_intensity", cg.uint8), ) -async def max7219digit_intensity_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]) - template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/max9611/sensor.py b/esphome/components/max9611/sensor.py index 9332274a955..e6309b21ccb 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -77,19 +77,8 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_current_resistor(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_gain(config[CONF_GAIN])) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_watt_sensor(sens)) - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temp_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_watt_sensor) + await sensors(CONF_TEMPERATURE, var.set_temp_sensor) diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 33abbe847a3..ef8b5684535 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -18,19 +18,18 @@ void MCP3204::dump_config() { } float MCP3204::read_data(uint8_t pin, bool differential) { - uint8_t command, b0, b1; - - command = (1 << 6) | // start bit - ((differential ? 0 : 1) << 5) | // single or differential bit - ((pin & 0x07) << 2); // pin + const uint8_t command = (1 << 6) | // start bit + ((differential ? 0 : 1) << 5) | // single or differential bit + ((pin & 0x07) << 2); // pin + // One full-duplex transaction: command out, 12-bit result back in bytes 1 and 2. + // Word aligned so ESP-IDF DMA uses the buffer in place; only ESP32-P4 also checks the length and bounces. + alignas(4) uint8_t buffer[3] = {command, 0x00, 0x00}; this->enable(); - this->transfer_byte(command); - b0 = this->transfer_byte(0x00); - b1 = this->transfer_byte(0x00); + this->transfer_array(buffer, sizeof(buffer)); this->disable(); - uint16_t digital_value = encode_uint16(b0, b1) >> 4; + uint16_t digital_value = encode_uint16(buffer[1], buffer[2]) >> 4; return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index db1a1e6a29f..b95a9e15100 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -121,13 +119,6 @@ async def to_code(config: ConfigType) -> None: # ---- Actions ---- -WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action) -WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action) -WiperStoreNonvolatileAction = mcp4461_ns.class_( - "WiperStoreNonvolatileAction", automation.Action -) -WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action) - WIPER_ACTION_SCHEMA = automation.maybe_simple_id( {cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)} ) @@ -144,51 +135,33 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True -) -async def mcp4461_wiper_step_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - wiper = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, wiper) +def _char_literal(config: ConfigType, value: str) -> str: + return f"'{value}'" -@automation.register_action( - "mcp4461.wiper.store_nonvolatile", - WiperStoreNonvolatileAction, +automation.register_apply_action( + "mcp4461.wiper.increase", WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("increase_wiper()"), ) -async def mcp4461_wiper_store_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - wiper = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, wiper) +automation.register_apply_action( + "mcp4461.wiper.decrease", + WIPER_ACTION_SCHEMA, + automation.ApplyCall("decrease_wiper()"), +) -@automation.register_action( +automation.register_apply_action( + "mcp4461.wiper.store_nonvolatile", + WIPER_ACTION_SCHEMA, + automation.ApplyCall("store_nonvolatile()"), +) + +automation.register_apply_action( "mcp4461.wiper.set_terminal", - WiperSetTerminalAction, TERMINAL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_terminal({}, {})", + ((CONF_TERMINAL, cg.char, _char_literal), (CONF_ENABLE, cg.bool_)), + ), ) -async def mcp4461_wiper_terminal_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - wiper = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable( - action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] - ) diff --git a/esphome/components/mcp4461/output/automation.h b/esphome/components/mcp4461/output/automation.h deleted file mode 100644 index 4be317b2f8f..00000000000 --- a/esphome/components/mcp4461/output/automation.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mcp4461_output.h" - -namespace esphome::mcp4461 { - -template class WiperIncreaseAction : public Action { - public: - explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->increase_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperDecreaseAction : public Action { - public: - explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->decrease_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -// Persist the current level to the chip's nonvolatile register immediately — useful with -// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to -// bypass the stability delay of the automatic mirroring. -template class WiperStoreNonvolatileAction : public Action { - public: - explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->store_nonvolatile(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperSetTerminalAction : public Action { - public: - WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) - : wiper_(wiper), terminal_(terminal), enable_(enable) {} - void play(Ts... x) override { - if (this->enable_) { - this->wiper_->enable_terminal(this->terminal_); - } else { - this->wiper_->disable_terminal(this->terminal_); - } - } - - protected: - Mcp4461Wiper *wiper_; - char terminal_; - bool enable_; -}; - -} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 5c373ddc7d2..d38eed4d096 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() { return this->state_; } -void Mcp4461Wiper::set_state(bool state) { - if (state) { - this->turn_on(); - } else { - this->turn_off(); - } -} - void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); } void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index c8d1ef1ec51..f0af6037e77 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedenable_terminal(terminal); + } else { + this->disable_terminal(terminal); + } + } /// @brief Immediately persist the current wiper level to the chip's nonvolatile register /// (independent of the deferred nonvolatile mirroring / its stability delay) void store_nonvolatile(); diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index f039bb69f03..0fb24fdf1df 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, + CONF_MDNS, + CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, CONF_SERVICE, @@ -184,6 +186,28 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def request_service_enable_disable() -> bool: + """Request MDNSComponent::set_service_enabled() support. + + ESP32 only, not with OpenThread. Returns True when the + USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. + + Public API for external components. Do not remove. + """ + mdns_config = CORE.config.get(CONF_MDNS) + if ( + mdns_config is None + or mdns_config[CONF_DISABLED] + or not CORE.is_esp32 + or CONF_OPENTHREAD in CORE.config + ): + return False + cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") + # Services must stay stored so a disabled service can be re-registered + enable_mdns_storage() + return True + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: @@ -192,6 +216,8 @@ async def to_code(config: ConfigType) -> None: if CORE.using_arduino: if CORE.is_esp8266: 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: cg.add_library("LEAmDNS", None) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index fa39e86ed0d..4f236153c2c 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -118,6 +118,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Starts disabled; the sendspin hub enables it once its server is running + sendspin_service.enabled = false; +#endif #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f97e8cb996..93d9eb1e385 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" // On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by // IP state listener events on whichever network interface is configured. #if (defined(USE_ESP8266) || defined(USE_RP2)) && \ @@ -39,12 +40,7 @@ struct MDNSString; // Macro to cast string literals to MDNSString* (works on all platforms) #define MDNS_STR(name) (reinterpret_cast(name)) -#ifdef USE_ESP8266 -#include -#define MDNS_STR_ARG(s) ((PGM_P) (s)) -#else -#define MDNS_STR_ARG(s) (reinterpret_cast(s)) -#endif +#define MDNS_STR_ARG(s) (reinterpret_cast(s)) // Service count is calculated at compile time by Python codegen // MDNS_SERVICE_COUNT will always be defined @@ -63,6 +59,9 @@ struct MDNSService { const MDNSString *proto; TemplatableFn port; FixedVector txt_records; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool enabled{true}; +#endif }; class MDNSComponent final : public Component @@ -112,6 +111,19 @@ class MDNSComponent final : public Component const StaticVector &get_services() const { return this->services_; } #endif +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifndef USE_MDNS_STORE_SERVICES +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" +#endif +#ifdef USE_OPENTHREAD +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#endif + /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). + /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. + /// Returns true if the service is in the requested state afterwards. Blocks briefly on the mDNS task. + bool set_service_enabled(const char *service_type, const char *proto, bool enabled); +#endif + void on_shutdown() override; #ifdef USE_MDNS_DYNAMIC_TXT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 17000a2bd76..48df61326e2 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32) && defined(USE_MDNS) #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -11,6 +12,23 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; +#ifndef USE_OPENTHREAD +static esp_err_t add_service(const MDNSService &service) { + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &record = service.txt_records[i]; + // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ + // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); + } + uint16_t port = service.port.value(); + return mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, + txt_records.get(), service.txt_records.size()); +} +#endif + static void register_esp32(MDNSComponent *comp, StaticVector &services) { #ifdef USE_OPENTHREAD // OpenThread handles service registration via SRP client @@ -27,27 +45,50 @@ static void register_esp32(MDNSComponent *comp, StaticVector txt_records(service.txt_records.size()); - for (size_t i = 0; i < service.txt_records.size(); i++) { - const auto &record = service.txt_records[i]; - // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ - // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records.get()[i].key = MDNS_STR_ARG(record.key); - txt_records.get()[i].value = MDNS_STR_ARG(record.value); - } - uint16_t port = service.port.value(); - err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, - txt_records.get(), service.txt_records.size()); - + for (auto &service : services) { +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + if (!service.enabled) + continue; +#endif + err = add_service(service); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err)); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Let a later enable call retry + service.enabled = false; +#endif } } #endif } +#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD) +bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) { + // services_ is compiled in setup() + if (!this->is_ready()) { + ESP_LOGW(TAG, "Cannot %s service %s before setup", enabled ? "enable" : "disable", service_type); + return false; + } + for (auto &service : this->services_) { + if (strcmp(MDNS_STR_ARG(service.service_type), service_type) != 0 || + strcmp(MDNS_STR_ARG(service.proto), proto) != 0) { + continue; + } + if (service.enabled == enabled) + return true; + esp_err_t err = enabled ? add_service(service) : mdns_service_remove(service_type, proto); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to %s service %s: %s", enabled ? "enable" : "disable", service_type, esp_err_to_name(err)); + return false; + } + service.enabled = enabled; + return true; + } + ESP_LOGW(TAG, "Service %s not found", service_type); + return false; +} +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 1f0b3c9519d..0e600d3bac1 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -13,8 +13,47 @@ 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 &services) { - MDNS.begin(App.get_name().c_str()); + mdns_responder.begin(App.get_name().c_str()); for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is @@ -30,10 +69,10 @@ static void register_esp8266(MDNSComponent *, StaticVectoris_roaming() || (!wifi->is_connected() && !wifi->is_ap_active())) return; #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); }); } @@ -81,7 +120,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: #endif void MDNSComponent::on_shutdown() { - MDNS.close(); + mdns_responder.close_guarded(); delay(10); } diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index aa1e88dca95..d99e7877bf2 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -152,7 +152,7 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_ANNOUNCEMENT = "on_announcement" CONF_MEDIA_URL = "media_url" -# Command actions that all share the same schema and codegen handler +# Command actions that all share the same schema and only differ in the command sent _COMMAND_ACTIONS = [ "play", "pause", @@ -190,27 +190,17 @@ _STATE_TRIGGERS = ( (CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), ) -# State conditions that all share the same schema and codegen handler -_STATE_CONDITIONS = [ - "idle", - "paused", - "playing", - "announcing", - "on", - "off", - "muted", -] +# State conditions: (config_key suffix, checked state) +_STATE_CONDITIONS = ( + ("idle", MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), + ("paused", MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED), + ("playing", MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), + ("announcing", MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING), + ("on", MediaPlayerState.MEDIA_PLAYER_STATE_ON), + ("off", MediaPlayerState.MEDIA_PLAYER_STATE_OFF), +) -# Special action classes with custom schemas/handlers -PlayMediaAction = media_player_ns.class_( - "PlayMediaAction", automation.Action, cg.Parented.template(MediaPlayer) -) -EnqueueMediaAction = media_player_ns.class_( - "EnqueueMediaAction", automation.Action, cg.Parented.template(MediaPlayer) -) -VolumeSetAction = media_player_ns.class_( - "VolumeSetAction", automation.Action, cg.Parented.template(MediaPlayer) -) +MediaPlayerCommand = media_player_ns.enum("MediaPlayerCommand", is_class=True) _CALLBACK_AUTOMATIONS = ( @@ -329,81 +319,60 @@ _MEDIA_URL_ACTION_SCHEMA = cv.maybe_simple_value( ) -async def _media_action_handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - media_url = await cg.templatable(config[CONF_MEDIA_URL], args, cg.std_string) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - cg.add(var.set_media_url(media_url)) - cg.add(var.set_announcement(announcement)) - return var +_ANNOUNCEMENT_FIELD = automation.ApplyField( + CONF_ANNOUNCEMENT, "set_announcement", cg.bool_ +) +_MEDIA_URL_FIELD = automation.ApplyField(CONF_MEDIA_URL, "set_media_url", cg.std_string) -automation.register_action( +def _set_command(command_name: str) -> automation.ApplyCall: + command = getattr( + MediaPlayerCommand, f"MEDIA_PLAYER_COMMAND_{command_name.upper()}" + ) + return automation.ApplyCall(f"set_command({command})") + + +automation.register_apply_action( "media_player.play_media", - PlayMediaAction, _MEDIA_URL_ACTION_SCHEMA, - synchronous=True, -)(_media_action_handler) + _MEDIA_URL_FIELD, + _ANNOUNCEMENT_FIELD, + call="make_call", +) -automation.register_action( +automation.register_apply_action( "media_player.enqueue", - EnqueueMediaAction, _MEDIA_URL_ACTION_SCHEMA, - synchronous=True, -)(_media_action_handler) + _set_command("enqueue"), + _MEDIA_URL_FIELD, + _ANNOUNCEMENT_FIELD, + call="make_call", +) + +for _action_name in _COMMAND_ACTIONS: + automation.register_apply_action( + f"media_player.{_action_name}", + MEDIA_PLAYER_ACTION_SCHEMA, + _set_command(_action_name), + _ANNOUNCEMENT_FIELD, + call="make_call", + ) -def _snake_to_camel(name): - return "".join(word.capitalize() for word in name.split("_")) +for _condition_name, _state in _STATE_CONDITIONS: + automation.register_apply_condition( + f"media_player.is_{_condition_name}", + MEDIA_PLAYER_CONDITION_SCHEMA, + f"state == {_state}", + ) + +automation.register_apply_condition( + "media_player.is_muted", MEDIA_PLAYER_CONDITION_SCHEMA, "is_muted()" +) -def _register_command_actions(): - async def handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - cg.add(var.set_announcement(announcement)) - return var - - for action_name in _COMMAND_ACTIONS: - class_name = f"{_snake_to_camel(action_name)}Action" - action_class = media_player_ns.class_( - class_name, automation.Action, cg.Parented.template(MediaPlayer) - ) - automation.register_action( - f"media_player.{action_name}", - action_class, - MEDIA_PLAYER_ACTION_SCHEMA, - synchronous=True, - )(handler) - - -_register_command_actions() - - -def _register_state_conditions(): - async def handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - for condition_name in _STATE_CONDITIONS: - class_name = f"Is{_snake_to_camel(condition_name)}Condition" - condition_class = media_player_ns.class_(class_name, automation.Condition) - automation.register_condition( - f"media_player.is_{condition_name}", - condition_class, - MEDIA_PLAYER_CONDITION_SCHEMA, - )(handler) - - -_register_state_conditions() - - -@automation.register_action( +automation.register_apply_action( "media_player.volume_set", - VolumeSetAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(MediaPlayer), @@ -411,14 +380,9 @@ _register_state_conditions() }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), + call="make_call", ) -async def media_player_volume_set_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) - cg.add(var.set_volume(volume)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 899acfefdfb..e4bb365c82a 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -5,76 +5,6 @@ namespace esphome::media_player { -template -class MediaPlayerCommandAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, announcement); - void play(const Ts &...x) override { - this->parent_->make_call().set_command(Command).set_announcement(this->announcement_.value(x...)).perform(); - } -}; - -template -using PlayAction = MediaPlayerCommandAction; -template -using PauseAction = MediaPlayerCommandAction; -template -using StopAction = MediaPlayerCommandAction; -template -using ToggleAction = MediaPlayerCommandAction; -template -using VolumeUpAction = MediaPlayerCommandAction; -template -using VolumeDownAction = MediaPlayerCommandAction; -template -using TurnOnAction = MediaPlayerCommandAction; -template -using TurnOffAction = MediaPlayerCommandAction; -template -using NextAction = MediaPlayerCommandAction; -template -using PreviousAction = MediaPlayerCommandAction; -template -using MuteAction = MediaPlayerCommandAction; -template -using UnmuteAction = MediaPlayerCommandAction; -template -using RepeatOffAction = MediaPlayerCommandAction; -template -using RepeatOneAction = MediaPlayerCommandAction; -template -using RepeatAllAction = MediaPlayerCommandAction; -template -using ShuffleAction = MediaPlayerCommandAction; -template -using UnshuffleAction = MediaPlayerCommandAction; -template -using GroupJoinAction = MediaPlayerCommandAction; -template -using ClearPlaylistAction = MediaPlayerCommandAction; - -template -class MediaPlayerMediaAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, media_url) - TEMPLATABLE_VALUE(bool, announcement) - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - if constexpr (Command != MediaPlayerCommand::MEDIA_PLAYER_COMMAND_PLAY) - call.set_command(Command); - call.set_media_url(this->media_url_.value(x...)).set_announcement(this->announcement_.value(x...)).perform(); - } -}; - -template -using PlayMediaAction = MediaPlayerMediaAction; -template -using EnqueueMediaAction = MediaPlayerMediaAction; - -template class VolumeSetAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, volume) - void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } -}; - /// Callback forwarder that triggers an Automation<> on any state change. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. struct StateAnyForwarder { @@ -97,41 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } -}; - -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } -}; - -template class IsPausedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } -}; - -template class IsAnnouncingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { - return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; - } -}; - -template class IsOnCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } -}; - -template class IsOffCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } -}; - -template class IsMutedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_muted(); } -}; - } // namespace esphome::media_player diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index 3cef3a3930d..0aacdda8886 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -49,27 +48,4 @@ class MHZ19Component final : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_zero(); } -}; - -template class MHZ19ABCEnableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->abc_enable(); } -}; - -template class MHZ19ABCDisableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->abc_disable(); } -}; - -template -class MHZ19DetectionRangeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) - - void play(const Ts &...x) override { this->parent_->range_set(this->detection_range_.value(x...)); } -}; - } // namespace esphome::mhz19 diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 5852686608c..9b0eeeb28ea 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,8 +15,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -26,21 +24,6 @@ CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") MHZ19Component = mhz19_ns.class_("MHZ19Component", cg.PollingComponent, uart.UARTDevice) -MHZ19CalibrateZeroAction = mhz19_ns.class_( - "MHZ19CalibrateZeroAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19ABCEnableAction = mhz19_ns.class_( - "MHZ19ABCEnableAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19ABCDisableAction = mhz19_ns.class_( - "MHZ19ABCDisableAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19DetectionRangeSetAction = mhz19_ns.class_( - "MHZ19DetectionRangeSetAction", - automation.Action, - cg.Parented.template(MHZ19Component), -) - mhz19_detection_range = mhz19_ns.enum("MHZ19DetectionRange") MHZ19_DETECTION_RANGE_ENUM = { 2000: mhz19_detection_range.MHZ19_DETECTION_RANGE_0_2000PPM, @@ -122,33 +105,14 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "mhz19.calibrate_zero", - MHZ19CalibrateZeroAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "mhz19.abc_enable", - MHZ19ABCEnableAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "mhz19.abc_disable", - MHZ19ABCDisableAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def mhz19_no_args_action_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]) - return var +for _name, _call in ( + ("mhz19.calibrate_zero", "calibrate_zero()"), + ("mhz19.abc_enable", "abc_enable()"), + ("mhz19.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) RANGE_ACTION_SCHEMA = maybe_simple_id( @@ -161,21 +125,8 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "mhz19.detection_range_set", - MHZ19DetectionRangeSetAction, RANGE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_DETECTION_RANGE, "range_set", mhz19_detection_range), ) -async def mhz19_detection_range_set_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]) - detection_range = config.get(CONF_DETECTION_RANGE) - template_ = await cg.templatable(detection_range, args, mhz19_detection_range) - cg.add(var.set_detection_range(template_)) - return var diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 092c4977ce1..f04f784ebcf 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -6,7 +6,6 @@ import re from urllib.parse import urljoin from esphome import automation, external_files, git -from esphome.automation import register_action, register_condition from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram @@ -58,17 +57,6 @@ micro_wake_word_ns = cg.esphome_ns.namespace("micro_wake_word") MicroWakeWord = micro_wake_word_ns.class_("MicroWakeWord", cg.Component) -DisableModelAction = micro_wake_word_ns.class_("DisableModelAction", automation.Action) -EnableModelAction = micro_wake_word_ns.class_("EnableModelAction", automation.Action) -StartAction = micro_wake_word_ns.class_("StartAction", automation.Action) -StopAction = micro_wake_word_ns.class_("StopAction", automation.Action) - -ModelIsEnabledCondition = micro_wake_word_ns.class_( - "ModelIsEnabledCondition", automation.Condition -) -IsRunningCondition = micro_wake_word_ns.class_( - "IsRunningCondition", automation.Condition -) WakeWordModel = micro_wake_word_ns.class_("WakeWordModel") @@ -619,23 +607,19 @@ async def to_code(config): MICRO_WAKE_WORD_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(MicroWakeWord)}) - -@register_action( +automation.register_apply_action( "micro_wake_word.start", - StartAction, MICRO_WAKE_WORD_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("start()"), ) -@register_action( - "micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "micro_wake_word.stop", + MICRO_WAKE_WORD_ACTION_SCHEMA, + automation.ApplyCall("stop()"), ) -@register_condition( - "micro_wake_word.is_running", IsRunningCondition, MICRO_WAKE_WORD_ACTION_SCHEMA +automation.register_apply_condition( + "micro_wake_word.is_running", MICRO_WAKE_WORD_ACTION_SCHEMA, "is_running()" ) -async def micro_wake_word_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( @@ -644,24 +628,18 @@ MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( } ) - -@register_action( +automation.register_apply_action( "micro_wake_word.enable_model", - EnableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("enable()"), ) -@register_action( +automation.register_apply_action( "micro_wake_word.disable_model", - DisableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("disable()"), ) -@register_condition( +automation.register_apply_condition( "micro_wake_word.model_is_enabled", - ModelIsEnabledCondition, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + "is_enabled()", ) -async def model_action(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h deleted file mode 100644 index 59dfc624fad..00000000000 --- a/esphome/components/micro_wake_word/automation.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include "micro_wake_word.h" -#include "streaming_model.h" - -#ifdef USE_ESP32 - -namespace esphome::micro_wake_word { - -template class StartAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class IsRunningCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class EnableModelAction final : public Action { - public: - explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - void play(const Ts &...x) override { this->wake_word_model_->enable(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -template class DisableModelAction final : public Action { - public: - explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - void play(const Ts &...x) override { this->wake_word_model_->disable(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -template class ModelIsEnabledCondition final : public Condition { - public: - explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -} // namespace esphome::micro_wake_word - -#endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 3dadb78077d..cf239be6960 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -129,7 +129,7 @@ void MicroWakeWord::setup() { return; } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task // to drain it - reset() is a consumer operation and must run on the inference task's thread. // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. @@ -446,9 +446,9 @@ void MicroWakeWord::loop() { xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); } - if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp index a7326ab77a0..c235e290ec2 100644 --- a/esphome/components/micro_wake_word/model_data.cpp +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -87,7 +87,8 @@ bool ModelData::validate_and_mark_ready() { const tflite::Model *model = tflite::GetModel(this->data_); if (model->version() != TFLITE_SCHEMA_VERSION) { - ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %d)", TFLITE_SCHEMA_VERSION, model->version()); + ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %" PRIu32 ")", TFLITE_SCHEMA_VERSION, + model->version()); return false; } diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 9a3f5b43e7b..c604f64d775 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -14,9 +14,9 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -29,30 +29,11 @@ microphone_ns = cg.esphome_ns.namespace("microphone") Microphone = microphone_ns.class_("Microphone") MicrophoneSource = microphone_ns.class_("MicrophoneSource") -CaptureAction = microphone_ns.class_( - "CaptureAction", automation.Action, cg.Parented.template(Microphone) -) -StopCaptureAction = microphone_ns.class_( - "StopCaptureAction", automation.Action, cg.Parented.template(Microphone) -) -MuteAction = microphone_ns.class_( - "MuteAction", automation.Action, cg.Parented.template(Microphone) -) -UnmuteAction = microphone_ns.class_( - "UnmuteAction", automation.Action, cg.Parented.template(Microphone) -) - - DataTrigger = microphone_ns.class_( "DataTrigger", automation.Trigger.template(cg.std_vector.template(cg.uint8).operator("ref")), ) -IsCapturingCondition = microphone_ns.class_( - "IsCapturingCondition", automation.Condition -) -IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) - async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): @@ -189,44 +170,22 @@ async def microphone_source_to_code( return mic_source -async def microphone_action( - 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]) - return var +for _name, _call in ( + ("microphone.capture", "start()"), + ("microphone.stop_capture", "stop()"), + ("microphone.mute", "set_mute_state(true)"), + ("microphone.unmute", "set_mute_state(false)"), +): + automation.register_apply_action( + _name, MICROPHONE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -automation.register_action( - "microphone.capture", - CaptureAction, - MICROPHONE_ACTION_SCHEMA, - synchronous=True, -)(microphone_action) - -automation.register_action( - "microphone.stop_capture", - StopCaptureAction, - MICROPHONE_ACTION_SCHEMA, - synchronous=True, -)(microphone_action) - -automation.register_action( - "microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True -)(microphone_action) -automation.register_action( - "microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True -)(microphone_action) - -automation.register_condition( - "microphone.is_capturing", IsCapturingCondition, MICROPHONE_ACTION_SCHEMA -)(microphone_action) -automation.register_condition( - "microphone.is_muted", IsMutedCondition, MICROPHONE_ACTION_SCHEMA -)(microphone_action) +automation.register_apply_condition( + "microphone.is_capturing", MICROPHONE_ACTION_SCHEMA, "is_running()" +) +automation.register_apply_condition( + "microphone.is_muted", MICROPHONE_ACTION_SCHEMA, "get_mute_state()" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index c28616a290a..0fac18d8283 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,21 +7,6 @@ namespace esphome::microphone { -template class CaptureAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopCaptureAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class MuteAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_mute_state(true); } -}; -template class UnmuteAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_mute_state(false); } -}; - class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { @@ -29,14 +14,4 @@ class DataTrigger final : public Trigger &> { } }; -template class IsCapturingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class IsMutedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } -}; - } // namespace esphome::microphone diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index 7be3b8cdb59..d7a33524322 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -48,7 +48,7 @@ class MicrophoneSource final { template void add_data_callback(F &&data_callback) { this->mic_->add_data_callback([this, data_callback](const std::vector &data) { if (this->enabled_ || this->passive_) { - if (this->processed_samples_.use_count() == 0) { + if (this->processed_samples_ == nullptr) { // Create vector if its unused this->processed_samples_ = std::make_shared>(); } diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h deleted file mode 100644 index 9572ec6c650..00000000000 --- a/esphome/components/midea/ac_automations.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) - -#include "esphome/core/automation.h" -#include "air_conditioner.h" - -namespace esphome::midea::ac { - -template class MideaActionBase : public Action { - public: - void set_parent(AirConditioner *parent) { this->parent_ = parent; } - - protected: - AirConditioner *parent_; -}; - -template class FollowMeAction : public MideaActionBase { - TEMPLATABLE_VALUE(float, temperature) - TEMPLATABLE_VALUE(bool, use_fahrenheit) - TEMPLATABLE_VALUE(bool, beeper) - - void play(const Ts &...x) override { - this->parent_->do_follow_me(this->temperature_.value(x...), this->use_fahrenheit_.value(x...), - this->beeper_.value(x...)); - } -}; - -template class SwingStepAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_swing_step(); } -}; - -template class DisplayToggleAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_display_toggle(); } -}; - -template class BeeperOnAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_beeper_on(); } -}; - -template class BeeperOffAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_beeper_off(); } -}; - -template class PowerOnAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_on(); } -}; - -template class PowerOffAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_off(); } -}; - -template class PowerToggleAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_toggle(); } -}; - -} // namespace esphome::midea::ac - -#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 0e03bca2336..e7a684babea 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import climate, remote_transmitter, sensor, uart +from esphome.components import climate, remote_base, remote_transmitter, sensor, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode from esphome.components.remote_base import CONF_TRANSMITTER_ID import esphome.config_validation as cv @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT, ) -from esphome.core import CORE, coroutine +from esphome.core import CORE CODEOWNERS = ["@dudanov"] DEPENDENCIES = ["climate", "uart"] @@ -53,25 +53,6 @@ def templatize(value): return cv.Schema(ret) -def register_action(name, type_, schema): - validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action( - f"midea_ac.{name}", type_, validator, synchronous=True - ) - - def decorator(func): - async def new_func(config, action_id, template_arg, args): - ac_ = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(ac_)) - await coroutine(func)(var, config, args) - return var - - return registerer(new_func) - - return decorator - - ALLOWED_CLIMATE_MODES = { "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, "COOL": ClimateMode.CLIMATE_MODE_COOL, @@ -161,16 +142,6 @@ CONFIG_SCHEMA = cv.All( ), ) -# Actions -FollowMeAction = midea_ac_ns.class_("FollowMeAction", automation.Action) -DisplayToggleAction = midea_ac_ns.class_("DisplayToggleAction", automation.Action) -SwingStepAction = midea_ac_ns.class_("SwingStepAction", automation.Action) -BeeperOnAction = midea_ac_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = midea_ac_ns.class_("BeeperOffAction", automation.Action) -PowerOnAction = midea_ac_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = midea_ac_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = midea_ac_ns.class_("PowerToggleAction", automation.Action) - MIDEA_ACTION_BASE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(AirConditioner), @@ -187,84 +158,31 @@ MIDEA_FOLLOW_ME_SCHEMA = cv.Schema( ) -@register_action("follow_me", FollowMeAction, MIDEA_FOLLOW_ME_SCHEMA) -async def follow_me_to_code(var, config, args): - template_ = await cg.templatable(config[CONF_BEEPER], args, cg.bool_) - cg.add(var.set_beeper(template_)) - template_ = await cg.templatable(config[CONF_USE_FAHRENHEIT], args, cg.bool_) - cg.add(var.set_use_fahrenheit(template_)) - template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_temperature(template_)) - - -# Toggle Display action -@register_action( - "display_toggle", - DisplayToggleAction, - cv.Schema({}), +automation.register_apply_action( + "midea_ac.follow_me", + templatize(MIDEA_FOLLOW_ME_SCHEMA).extend(MIDEA_ACTION_BASE_SCHEMA), + automation.ApplyCall( + "do_follow_me({}, {}, {})", + ( + (CONF_TEMPERATURE, cg.float_), + (CONF_USE_FAHRENHEIT, cg.bool_), + (CONF_BEEPER, cg.bool_), + ), + ), ) -async def display_toggle_to_code(var, config, args): - pass - -# Swing Step action -@register_action( - "swing_step", - SwingStepAction, - cv.Schema({}), -) -async def swing_step_to_code(var, config, args): - pass - - -# Beeper On action -@register_action( - "beeper_on", - BeeperOnAction, - cv.Schema({}), -) -async def beeper_on_to_code(var, config, args): - pass - - -# Beeper Off action -@register_action( - "beeper_off", - BeeperOffAction, - cv.Schema({}), -) -async def beeper_off_to_code(var, config, args): - pass - - -# Power On action -@register_action( - "power_on", - PowerOnAction, - cv.Schema({}), -) -async def power_on_to_code(var, config, args): - pass - - -# Power Off action -@register_action( - "power_off", - PowerOffAction, - cv.Schema({}), -) -async def power_off_to_code(var, config, args): - pass - - -# Power Toggle action -@register_action( - "power_toggle", - PowerToggleAction, - cv.Schema({}), -) -async def power_inv_to_code(var, config, args): - pass +for _name, _call in ( + ("midea_ac.display_toggle", "do_display_toggle()"), + ("midea_ac.swing_step", "do_swing_step()"), + ("midea_ac.beeper_on", "do_beeper_on()"), + ("midea_ac.beeper_off", "do_beeper_off()"), + ("midea_ac.power_on", "do_power_on()"), + ("midea_ac.power_off", "do_power_off()"), + ("midea_ac.power_toggle", "do_power_toggle()"), +): + automation.register_apply_action( + _name, MIDEA_ACTION_BASE_SCHEMA, automation.ApplyCall(_call) + ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( @@ -280,6 +198,7 @@ async def to_code(config): cg.add(var.set_response_timeout(config[CONF_TIMEOUT].total_milliseconds)) cg.add(var.set_request_attempts(config[CONF_NUM_ATTEMPTS])) if CONF_TRANSMITTER_ID in config: + remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) cg.add(var.set_transmitter(transmitter_)) diff --git a/esphome/components/midea_ir/climate.py b/esphome/components/midea_ir/climate.py index 84bfeab0d46..e1b2b56ada4 100644 --- a/esphome/components/midea_ir/climate.py +++ b/esphome/components/midea_ir/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_USE_FAHRENHEIT from esphome.types import ConfigType @@ -19,5 +19,9 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MideaIR).extend( async def to_code(config: ConfigType) -> None: + # midea_ir uses MideaProtocol from C++ and auto-loads coolix, whose coolix.cpp uses + # CoolixProtocol even when no coolix climate is configured + remote_base.request_protocol("midea") + remote_base.request_protocol("coolix") var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3f73f963277..50b84b630d1 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -606,11 +606,11 @@ class DriverChip: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. - Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence + Append SLPOUT (if not suppressed by the model) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True If add_reset is True, a reset is prepended: a software reset when no reset pin is configured (and the model doesn't skip it), followed by a settling delay that - both a software and a hardware reset require. + both a software and a hardware reset require. The delay length is set via reset_delay, and defaults to 10ms. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -620,12 +620,16 @@ class DriverChip: sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] if add_reset: + # Matches the 1-255ms range map_sequence() already allows for a "delay N" entry. + reset_delay = self.get_default("reset_delay", 10) + if reset_delay < 1 or reset_delay > 255: + raise ValueError("reset_delay must be between 1 and 255ms") reset: list = [] # A software reset is only needed when there is no hardware reset pin. if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): reset.append((SWRESET,)) # Both a software and a hardware reset need a settling delay before further commands. - reset.append(delay(10)) + reset.append(delay(reset_delay)) sequence = reset + sequence # Set pixel format if not already in the custom sequence diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0150cc25442..b6612038b6d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -259,7 +259,7 @@ bool MipiDsi::check_buffer_() { } void MipiDsi::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index b91528160e2..9cba56a9763 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -285,7 +285,7 @@ async def to_code(config: ConfigType) -> None: if CONF_SPI_ID in config: await spi.register_spi_device(var, config, write_only=True) - sequence = model.get_sequence(config) + sequence = model.get_sequence(config, add_reset=True) cg.add(var.set_init_sequence(sequence)) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index c11044c2882..034efb9c92a 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -44,8 +44,10 @@ void MipiRgb::setup_enables_() { void MipiRgbSpi::setup() { this->setup_enables_(); this->spi_setup(); - this->write_init_sequence_(); this->common_setup_(); + if (this->is_failed()) + return; + this->write_init_sequence_(); } void MipiRgbSpi::write_command_(uint8_t value) { this->enable(); @@ -259,7 +261,7 @@ bool MipiRgb::check_buffer_() { } void MipiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y) || this->is_failed()) + if (this->is_point_clipped(x, y) || this->is_failed()) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index cad5dc8e20d..b51e7447ad4 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -7,6 +7,10 @@ SDIR_CMD = 0xC7 class ST7701S(RgbDriverChip): + def __init__(self, *args, reset_delay=50, **kwargs): + kwargs["reset_delay"] = reset_delay + super().__init__(*args, **kwargs) + # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict) -> int: transform = self.get_transform(config) @@ -49,7 +53,6 @@ st7701s = ST7701S( pclk_frequency="16MHz", pclk_inverted=True, initsequence=( - (0x01,), # Software Reset (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0 (0xC0, 0x3B, 0x00), (0xC1, 0x0D, 0x02), (0xC2, 0x31, 0x05), (0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18,), diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7c..e97b0e3a310 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,20 +246,18 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - // Toggle D/C only while holding the bus; on boards where D/C doubles as - // another bus signal, driving it while another device owns the bus - // corrupts that device's transfer. this->enable(); this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); this->dc_pin_->digital_write(true); - this->disable(); + // hold the bus between command and data to avoid a glitch on the D/C line if (len != 0) { - this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); - this->disable(); } + this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { + // Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination. + // See https://github.com/esphome/esphome/pull/18529 this->enable(); this->dc_pin_->digital_write(false); this->write_byte(cmd); @@ -271,12 +269,14 @@ class MipiSpi : public display::Display, this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->enable(); + // DC must be stable before CS as the clock is gated by CS this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(cmd); - this->dc_pin_->digital_write(true); this->disable(); + this->dc_pin_->digital_write(true); for (size_t i = 0; i != len; i++) { + // must enable and disable for each byte based on empirical testing this->enable(); this->write_byte(0); this->write_byte(bytes[i]); @@ -604,7 +604,7 @@ class MipiSpiBuffer // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; if constexpr (not HAS_HARDWARE_ROTATION) { if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index d108ea46215..9cb355fe9c1 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -53,46 +53,6 @@ const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; const uint8_t MITSUBISHI_BYTE16 = 0x00; -climate::ClimateTraits MitsubishiClimate::traits() { - auto traits = climate::ClimateTraits(); - if (this->sensor_ != nullptr) { - traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - } - traits.set_visual_min_temperature(MITSUBISHI_TEMP_MIN); - traits.set_visual_max_temperature(MITSUBISHI_TEMP_MAX); - traits.set_visual_temperature_step(1.0f); - traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); - - if (this->supports_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - - if (this->supports_cool_ && this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); - - if (this->supports_dry_) - traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (this->supports_fan_only_) - traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); - - // Default to only 3 levels in ESPHome even if most unit supports 4. The 3rd level is not used. - traits.set_supported_fan_modes( - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}); - if (this->fan_mode_ == MITSUBISHI_FAN_Q4L) - traits.add_supported_fan_mode(climate::CLIMATE_FAN_QUIET); - if (/*this->fan_mode_ == MITSUBISHI_FAN_5L ||*/ this->fan_mode_ >= MITSUBISHI_FAN_4L) - traits.add_supported_fan_mode(climate::CLIMATE_FAN_MIDDLE); // Shouldn't be used for this but it helps - - traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}); - - traits.set_supported_presets({climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, - climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_SLEEP}); - - return traits; -} - void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 // Byte 5: On=0x20, Off: 0x00 @@ -142,7 +102,7 @@ void MitsubishiClimate::transmit_state() { default: remote_state[6] = MITSUBISHI_MODE_COOL; remote_state[8] = MITSUBISHI_MODE_A_COOL; - if (this->supports_heat_) { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) { remote_state[6] = MITSUBISHI_MODE_HEAT; remote_state[8] = MITSUBISHI_MODE_A_HEAT; } diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 7925b7ce44f..269d39b06b4 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -11,7 +11,7 @@ const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius // Fan mode -enum SetFanMode { +enum SetFanMode : uint8_t { MITSUBISHI_FAN_3L = 0, // 3 levels + auto MITSUBISHI_FAN_4L, // 4 levels + auto MITSUBISHI_FAN_Q4L, // Quiet + 4 levels + auto @@ -19,7 +19,7 @@ enum SetFanMode { }; // Enum to represent horizontal directios -enum HorizontalDirection { +enum HorizontalDirection : uint8_t { HORIZONTAL_DIRECTION_LEFT = 0x10, HORIZONTAL_DIRECTION_MIDDLE_LEFT = 0x20, HORIZONTAL_DIRECTION_MIDDLE = 0x30, @@ -29,7 +29,7 @@ enum HorizontalDirection { }; // Enum to represent vertical directions -enum VerticalDirection { +enum VerticalDirection : uint8_t { VERTICAL_DIRECTION_AUTO = 0x00, VERTICAL_DIRECTION_UP = 0x08, VERTICAL_DIRECTION_MIDDLE_UP = 0x10, @@ -38,23 +38,32 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 0x28, }; +// Fan modes offered by every set_fan_mode option. +static constexpr climate::ClimateFanModeMask MITSUBISHI_BASE_FAN_MODES{ + climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}; + class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() - : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_QUIET}, + : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, MITSUBISHI_BASE_FAN_MODES, {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}, {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_SLEEP}) {} - void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } - void set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } - void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } - void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_dry(bool supports_dry) { this->set_mode_supported_(climate::CLIMATE_MODE_DRY, supports_dry); } + void set_supports_fan_only(bool supports_fan_only) { + this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); + } - void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; } + void set_fan_mode(SetFanMode fan_mode) { + this->fan_mode_ = fan_mode; + this->fan_modes_ = MITSUBISHI_BASE_FAN_MODES; + if (fan_mode == MITSUBISHI_FAN_Q4L) + this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); + if (fan_mode >= MITSUBISHI_FAN_4L) + this->fan_modes_.insert(climate::CLIMATE_FAN_MIDDLE); // Shouldn't be used for this but it helps + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; @@ -74,8 +83,6 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; - - climate::ClimateTraits traits() override; }; } // namespace esphome::mitsubishi diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 470b7be5fc8..442869eec53 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -10,9 +10,8 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_USE_FAHRENHEIT, ) -from esphome.core import ID, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@crnjan"] DEPENDENCIES = ["uart"] @@ -32,7 +31,6 @@ MitsubishiCN105Component = mitsubishi_ns.class_( ) VaneState = mitsubishi_ns.struct("VaneState") -VaneCall = mitsubishi_ns.class_("VaneCall") VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") # The insertion order must match VALUES in @@ -47,22 +45,6 @@ VERTICAL_VANE_DIRECTIONS = { "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, } -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Component), -) - -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Component), -) - -VaneControlAction = mitsubishi_ns.class_( - "VaneControlAction", - automation.Action, -) CONFIG_SCHEMA = ( cv.Schema( @@ -117,8 +99,9 @@ async def to_code(config: ConfigType) -> None: ) ) cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) + # User lambdas may use unqualified enum names. + cg.add_global(mitsubishi_ns.using) if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): - cg.add_global(mitsubishi_ns.using) for conf in on_state: await automation.build_callback_automation( var, @@ -147,50 +130,19 @@ CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.set_remote_temperature", - SetRemoteTemperatureAction, REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_TEMPERATURE, "set_remote_temperature", cg.float_), ) -async def remote_temperature_action_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]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) - cg.add(var.set_temperature(temperature)) - return var - -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.clear_remote_temperature", - ClearRemoteTemperatureAction, CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("clear_remote_temperature()"), ) -async def clear_temperature_action_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]) - return var -VANE_CONTROL_FIELDS = ( - ( - (CONF_VERTICAL, CONF_DIRECTION), - "vertical.set_direction", - VerticalVaneMode, - ), -) - VANE_CONTROL_ACTION_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), @@ -205,46 +157,11 @@ VANE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.vane.control", - VaneControlAction, VANE_CONTROL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + (CONF_VERTICAL, CONF_DIRECTION), "vertical.set_direction", VerticalVaneMode + ), + call="make_vane_call", ) -async def vane_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - cg.add_global(mitsubishi_ns.using) - parent = await cg.get_variable(config[CONF_ID]) - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) - for t, name in args - ] - forwarded_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for path, setter, type_ in VANE_CONTROL_FIELDS: - if (section := config.get(path[0])) is None: - continue - if (value := section.get(path[1])) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, - normalized_args, - return_type=type_, - ) - body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - [(VaneCall.operator("ref"), "call"), *normalized_args], - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h deleted file mode 100644 index f9ca3a47e63..00000000000 --- a/esphome/components/mitsubishi_cn105/automation.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include "mitsubishi_cn105_component.h" - -#include "esphome/core/automation.h" - -#include - -namespace esphome::mitsubishi_cn105 { - -template -class SetRemoteTemperatureAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } -}; - -template -class ClearRemoteTemperatureAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } -}; - -template class VaneControlAction final : public Action { - public: - using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); - - VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_vane_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - MitsubishiCN105Component *parent_; - ApplyFn apply_; -}; - -} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 05a29b36654..756c8142c20 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import logging from esphome import automation @@ -12,10 +13,9 @@ from esphome.const import ( CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj +from esphome.core import CORE from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import ( CONF_MITSUBISHI_CN105_ID, @@ -44,20 +44,6 @@ MitsubishiCN105Climate = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) -# Legacy climate action compatibility. Remove in 2027.2.0. -LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( - "LegacySetRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Climate), -) - -# Legacy climate action compatibility. Remove in 2027.2.0. -LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( - "LegacyClearRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Climate), -) - # Legacy climate-owned hub compatibility. Remove in 2027.2.0. def _has_top_level_hub_config() -> bool: @@ -204,53 +190,36 @@ LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( ) -# Legacy climate action compatibility. Remove in 2027.2.0. -@automation.register_action( +def _warn_legacy_action(name: str) -> Callable[[ConfigType], ConfigType]: + # Legacy climate action compatibility. Remove in 2027.2.0. + def validator(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "The 'climate.%s.%s' action is deprecated. Use '%s.%s' instead. " + "It will be removed in ESPHome 2027.2.0.", + DOMAIN, + name, + DOMAIN, + name, + ) + return config + + return validator + + +automation.register_apply_action( f"climate.{DOMAIN}.set_remote_temperature", - LegacySetRemoteTemperatureAction, - LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + cv.All( + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, + _warn_legacy_action("set_remote_temperature"), + ), + automation.ApplyField(CONF_TEMPERATURE, "set_remote_temperature", cg.float_), ) -async def legacy_remote_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - _LOGGER.warning( - "The 'climate.%s.set_remote_temperature' action is deprecated. Use " - "'%s.set_remote_temperature' instead. It will be removed in ESPHome " - "2027.2.0.", - DOMAIN, - DOMAIN, - ) - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) - cg.add(var.set_temperature(temperature)) - return var - -# Legacy climate action compatibility. Remove in 2027.2.0. -@automation.register_action( +automation.register_apply_action( f"climate.{DOMAIN}.clear_remote_temperature", - LegacyClearRemoteTemperatureAction, - LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + cv.All( + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + _warn_legacy_action("clear_remote_temperature"), + ), + automation.ApplyCall("clear_remote_temperature()"), ) -async def legacy_clear_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - _LOGGER.warning( - "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " - "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " - "2027.2.0.", - DOMAIN, - DOMAIN, - ) - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index cea76278abd..fc665b079af 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -3,7 +3,6 @@ #include "mitsubishi_cn105_component.h" #include "mitsubishi_cn105.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" #include "mitsubishi_cn105_swing_mode_manager.h" @@ -31,20 +30,4 @@ class MitsubishiCN105Climate final : public climate::Climate, SwingModeManager swing_mode_manager_; }; -// Legacy climate action compatibility. Remove in 2027.2.0. -template -class LegacySetRemoteTemperatureAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } -}; - -// Legacy climate action compatibility. Remove in 2027.2.0. -template -class LegacyClearRemoteTemperatureAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } -}; - } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3746c019a0..44473b12c32 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,9 +15,7 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) -from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -32,10 +30,6 @@ CONF_DECIBEL_REDUCTION = "decibel_reduction" CONF_QUEUE_MODE = "queue_mode" CONF_SOURCE_SPEAKERS = "source_speakers" -DuckingApplyAction = mixer_speaker_ns.class_( - "DuckingApplyAction", automation.Action, cg.Parented.template(SourceSpeaker) -) - SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( { @@ -148,34 +142,21 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_source_speaker(source_speaker)) -@automation.register_action( +automation.register_apply_action( "mixer_speaker.apply_ducking", - DuckingApplyAction, cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=51) + cv.int_range(min=0, max=255) ), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds ), } ), - synchronous=True, + automation.ApplyCall( + "apply_ducking({}, {})", + ((CONF_DECIBEL_REDUCTION, cg.uint8), (CONF_DURATION, cg.uint32)), + ), ) -async def ducking_set_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]) - decibel_reduction = await cg.templatable( - config[CONF_DECIBEL_REDUCTION], args, cg.uint8 - ) - cg.add(var.set_decibel_reduction(decibel_reduction)) - duration = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(duration)) - return var diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h deleted file mode 100644 index ea51b6b8892..00000000000 --- a/esphome/components/mixer/speaker/automation.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mixer_speaker.h" - -#ifdef USE_ESP32 - -namespace esphome::mixer_speaker { -template class DuckingApplyAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, decibel_reduction); - TEMPLATABLE_VALUE(uint32_t, duration); - void play(const Ts &...x) override { - this->parent_->apply_ducking(this->decibel_reduction_.value(x...), this->duration_.value(x...)); - } -}; -} // namespace esphome::mixer_speaker - -#endif diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 6128dc37678..41b7123269a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ } size_t bytes_written = 0; std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer.use_count() > 0) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { @@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; - if (this->audio_source_.use_count() == 0) { + if (this->audio_source_ == nullptr) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { return ESP_ERR_NO_MEM; } @@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); + return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - esp_audio_libs::ducking::apply(audio_source->mutable_data(), - static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), - samples_to_duck, this->ducking_state_); + this->ducking_ramp_.process(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck); } return bytes_read; @@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; - esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); + this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { @@ -382,10 +382,11 @@ void MixerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MIXER_TASK_STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + // Keep a start request that arrived while the task was stopping, otherwise it is lost for good + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START); this->all_stopped_since_ms_ = 0; } @@ -496,7 +497,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr audio_source = speaker->get_audio_source().lock(); - if (audio_source.use_count() == 0) { + if (audio_source == nullptr) { // No audio source allocated, so skip processing this speaker continue; } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 00e89d17826..494443d6951 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,7 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" -#include // esp-audio-libs +#include // esp-audio-libs #include @@ -108,7 +108,7 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool pause_state_{false}; - esp_audio_libs::ducking::DuckingState ducking_state_{}; + esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing diff --git a/esphome/components/mk2pvrouter/binary_sensor/__init__.py b/esphome/components/mk2pvrouter/binary_sensor/__init__.py new file mode 100644 index 00000000000..527c6a1eb21 --- /dev/null +++ b/esphome/components/mk2pvrouter/binary_sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterBinarySensor = mk2pvrouter_ns.class_( + "Mk2PVRouterBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(Mk2PVRouterBinarySensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await binary_sensor.register_binary_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp new file mode 100644 index 00000000000..9201596af28 --- /dev/null +++ b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp @@ -0,0 +1,21 @@ +#include "mk2pvrouter_binary_sensor.h" +#include "esphome/core/log.h" +#include + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_binary_sensor"; + +Mk2PVRouterBinarySensor::Mk2PVRouterBinarySensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterBinarySensor::publish_val(const char *val) { + bool state = (strcmp(val, "0") != 0); + this->publish_state(state); +} + +void Mk2PVRouterBinarySensor::dump_config() { + LOG_BINARY_SENSOR(" ", "Mk2PVRouter Binary Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h new file mode 100644 index 00000000000..0cdbee1fd92 --- /dev/null +++ b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterBinarySensor final : public Mk2PVRouterListener, public binary_sensor::BinarySensor, public Component { + public: + explicit Mk2PVRouterBinarySensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index fc23cf49e80..1a6af4b1b39 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -33,7 +33,7 @@ class Mk2PVRouterListener { }; // Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners. -class Mk2PVRouter final : public Component, public uart::UARTDevice { +class Mk2PVRouter : public Component, public uart::UARTDevice { public: #ifdef MK2PVROUTER_LISTENER_COUNT void register_mk2pvrouter_listener(Mk2PVRouterListener *listener); diff --git a/esphome/components/mk2pvrouter/sensor/__init__.py b/esphome/components/mk2pvrouter/sensor/__init__.py index 14fc48a6260..92ca6ea898a 100644 --- a/esphome/components/mk2pvrouter/sensor/__init__.py +++ b/esphome/components/mk2pvrouter/sensor/__init__.py @@ -1,6 +1,31 @@ +from dataclasses import dataclass +from typing import Any + import esphome.codegen as cg from esphome.components import sensor -from esphome.const import CONF_ID, CONF_TAG +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_STATE_CLASS, + CONF_TAG, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_EMPTY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_NONE, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PERCENT, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) from esphome.types import ConfigType from .. import ( @@ -14,13 +39,89 @@ Mk2PVRouterSensor = mk2pvrouter_ns.class_( "Mk2PVRouterSensor", sensor.Sensor, cg.Component ) -CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend( - MK2PVROUTER_LISTENER_SCHEMA + +@dataclass(frozen=True) +class TagKind: + """Sensor defaults for one kind of Mk2PVRouter output.""" + + unit_of_measurement: str + device_class: str + state_class: str + accuracy_decimals: int + # The device sends the value * 100; Mk2PVRouterSensor::publish_val() corrects it. + scale_centi: bool = False + + def defaults(self) -> dict[str, Any]: + return { + CONF_UNIT_OF_MEASUREMENT: self.unit_of_measurement, + CONF_DEVICE_CLASS: self.device_class, + CONF_STATE_CLASS: self.state_class, + CONF_ACCURACY_DECIMALS: self.accuracy_decimals, + } + + +POWER = TagKind(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT, 0) +VOLTAGE = TagKind( + UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, 2, scale_centi=True +) +ENERGY = TagKind(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING, 0) +TEMPERATURE = TagKind( + UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, 2, scale_centi=True +) +RELAY_STATE = TagKind(UNIT_EMPTY, DEVICE_CLASS_EMPTY, STATE_CLASS_NONE, 0) +DIVERSION_RATE = TagKind(UNIT_PERCENT, DEVICE_CLASS_EMPTY, STATE_CLASS_MEASUREMENT, 0) + +# Keyed by (letter, indexed). Per the Mk2PVRouter firmware protocol, D and R mean different +# things bare and indexed: D is diverted power (W) but D1, D2, ... are diversion rates (%); +# R is mean relay power (W) but R1, R2, ... are relay states. T is always indexed, E never. +TAG_KINDS = { + ("P", False): POWER, + ("P", True): POWER, + ("D", False): POWER, + ("D", True): DIVERSION_RATE, + ("V", False): VOLTAGE, + ("V", True): VOLTAGE, + ("E", False): ENERGY, + ("T", True): TEMPERATURE, + ("R", False): POWER, + ("R", True): RELAY_STATE, +} + + +def tag_kind(tag: str) -> TagKind | None: + """The kind of a tag such as P, V1 or R10, or None for a tag that is not a known output.""" + tag = tag.upper() + index = tag[1:] + if index and not index.isdigit(): + return None + return TAG_KINDS.get((tag[:1], bool(index))) + + +def _inject_tag_defaults(config: ConfigType) -> ConfigType: + """Fill in the tag's defaults for keys the user did not set; the sensor schema validates them.""" + if ( + isinstance(config, dict) + and isinstance(config.get(CONF_TAG), str) + and (kind := tag_kind(config[CONF_TAG])) is not None + ): + config = {**kind.defaults(), **config} + return config + + +CONFIG_SCHEMA = cv.All( + _inject_tag_defaults, + sensor.sensor_schema( + Mk2PVRouterSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(MK2PVROUTER_LISTENER_SCHEMA), ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + tag = config[CONF_TAG] + kind = tag_kind(tag) + var = cg.new_Pvariable(config[CONF_ID], tag, kind is not None and kind.scale_centi) await cg.register_component(var, config) await sensor.register_sensor(var, config) mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp index 96f1ff5954e..5ea07307e49 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -5,7 +5,8 @@ namespace esphome::mk2pvrouter { static const char *const TAG = "mk2pvrouter_sensor"; -Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {} +Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag, bool scale_centi) + : Mk2PVRouterListener(tag), scale_centi_(scale_centi) {} void Mk2PVRouterSensor::publish_val(const char *val) { auto result = parse_number(val); @@ -13,7 +14,11 @@ void Mk2PVRouterSensor::publish_val(const char *val) { ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag()); return; } - this->publish_state(result.value()); + float value = result.value(); + if (this->scale_centi_) { + value *= 0.01f; + } + this->publish_state(value); } void Mk2PVRouterSensor::dump_config() { diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h index e4da41e384b..6ae4f2f0ad8 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h @@ -5,11 +5,15 @@ namespace esphome::mk2pvrouter { -class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component { +// Component before Sensor so the flag lands in Sensor's tail padding +class Mk2PVRouterSensor final : public Mk2PVRouterListener, public Component, public sensor::Sensor { public: - explicit Mk2PVRouterSensor(const char *tag); + Mk2PVRouterSensor(const char *tag, bool scale_centi); void publish_val(const char *val) override; void dump_config() override; + + protected: + bool scale_centi_; }; } // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/text_sensor/__init__.py b/esphome/components/mk2pvrouter/text_sensor/__init__.py new file mode 100644 index 00000000000..11689f8bc7b --- /dev/null +++ b/esphome/components/mk2pvrouter/text_sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterTextSensor = mk2pvrouter_ns.class_( + "Mk2PVRouterTextSensor", text_sensor.TextSensor, cg.Component +) + +CONFIG_SCHEMA = text_sensor.text_sensor_schema(Mk2PVRouterTextSensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await text_sensor.register_text_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp new file mode 100644 index 00000000000..4aea2ef7fc7 --- /dev/null +++ b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp @@ -0,0 +1,17 @@ +#include "mk2pvrouter_text_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_text_sensor"; + +Mk2PVRouterTextSensor::Mk2PVRouterTextSensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterTextSensor::publish_val(const char *val) { this->publish_state(val); } + +void Mk2PVRouterTextSensor::dump_config() { + LOG_TEXT_SENSOR(" ", "Mk2PVRouter Text Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h new file mode 100644 index 00000000000..590c67005c8 --- /dev/null +++ b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterTextSensor final : public Mk2PVRouterListener, public text_sensor::TextSensor, public Component { + public: + explicit Mk2PVRouterTextSensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2d3b6631bc6..508b3743d18 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F; static const char *const TAG = "mlx90614"; +// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures +static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3; + +// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first +static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); } + void MLX90614Component::setup() { - if (!this->write_emissivity_()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + if (std::isnan(this->emissivity_)) { return; } + this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS; + this->try_write_emissivity_(); + if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } +} + +void MLX90614Component::try_write_emissivity_() { + if (this->emissivity_write_attempts_ == 0) { + return; + } + if (this->write_emissivity_()) { + this->emissivity_write_attempts_ = 0; + return; + } + if (--this->emissivity_write_attempts_ == 0) { + ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS); + this->emissivity_write_failed_ = true; + } } bool MLX90614Component::write_emissivity_() { - if (std::isnan(this->emissivity_)) + // Skip the write when the EEPROM already holds the desired value to save write cycles + uint16_t current_emissivity; + if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) { + return false; + } + + const auto desired_emissivity = static_cast(this->emissivity_ * 0xFFFF); + if (current_emissivity == desired_emissivity) { return true; - uint16_t value = (uint16_t) (this->emissivity_ * 65535); - if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) { - return false; } - delay(10); - if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) { - return false; - } - delay(10); - return true; + + return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity); } -bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) { +bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) { + // The PEC covers the whole write transaction: SLA+W, command, data low, data high uint8_t buf[5]; buf[0] = this->address_ << 1; buf[1] = reg; - buf[2] = data & 0xFF; - buf[3] = data >> 8; - buf[4] = crc8(buf, 4, 0x00, 0x07, true); - return this->write_bytes(reg, buf + 2, 3); + + // See datasheet 8.3.3.1 EEPROM write sequence + // 1. Write 0x0000 into the cell of interest (erases the cell) + buf[2] = buf[3] = 0; + buf[4] = crc8_pec(buf, 4); + auto ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec); + return false; + } + + // 2. Wait at least 5ms + delay(10); + + // 3. Write the new value + if (data != 0) { + buf[2] = data & 0xFF; + buf[3] = data >> 8; + buf[4] = crc8_pec(buf, 4); + ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec); + return false; + } + // 4. Wait at least 5ms + delay(10); + } + + // 5. Read back to confirm the value was stored + uint16_t read_back; + ec = this->read_register_(reg, read_back); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec); + return false; + } + + if (read_back != data) { + ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back); + return false; + } + + return true; +} + +i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { + // The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high + uint8_t buf[6]; + buf[0] = this->address_ << 1; + buf[1] = reg; + buf[2] = (this->address_ << 1) | 0x01; + + const auto ec = this->read_register(reg, buf + 3, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "i2c read error %d", ec); + return ec; + } + + const auto expected_pec = crc8_pec(buf, 5); + if (buf[5] != expected_pec) { + ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]); + return i2c::ERROR_CRC; + } + + data = encode_uint16(buf[4], buf[3]); + return i2c::ERROR_OK; } void MLX90614Component::dump_config() { ESP_LOGCONFIG(TAG, "MLX90614:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->emissivity_write_attempts_ != 0) { + ESP_LOGW(TAG, " Emissivity not written yet, will retry"); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Ambient", this->ambient_sensor_); @@ -71,33 +156,41 @@ void MLX90614Component::dump_config() { } void MLX90614Component::update() { - uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; + // Temperature reads run regardless of the emissivity state so a failure still shows up as NAN + this->try_write_emissivity_(); + + // Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale + auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) { + if (sensor == nullptr) { + return i2c::ERROR_OK; + } + + uint16_t raw; + const auto ec = this->read_register_(reg, raw); + if (ec != i2c::ERROR_OK) { + sensor->publish_state(NAN); + return ec; + } + + // Bit 15 set means the device flagged the reading as invalid + const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f; + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature); + sensor->publish_state(temperature); + return ec; + }; + + const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1); + const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT); + + if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read some sensors")); + } else if (this->emissivity_write_failed_) { + this->status_set_warning(LOG_STR("Failed to write emissivity")); + } else if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } else { + this->status_clear_warning(); } - uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f; - float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f; - - ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient); - - if (this->ambient_sensor_ != nullptr && !std::isnan(ambient)) - this->ambient_sensor_->publish_state(ambient); - if (this->object_sensor_ != nullptr && !std::isnan(object)) - this->object_sensor_->publish_state(object); - this->status_clear_warning(); } } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 882ee45186a..758792acede 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { void set_emissivity(float emissivity) { emissivity_ = emissivity; } protected: + void try_write_emissivity_(); bool write_emissivity_(); - bool write_bytes_(uint8_t reg, uint16_t data); + bool write_register_(uint8_t reg, uint16_t data); + i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data); sensor::Sensor *ambient_sensor_{nullptr}; sensor::Sensor *object_sensor_{nullptr}; float emissivity_{NAN}; + // Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear + uint8_t emissivity_write_attempts_{0}; + bool emissivity_write_failed_{false}; }; } // namespace esphome::mlx90614 diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index a9f240508c1..70c70877b75 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -82,17 +82,10 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_datarate(auto_data_rate(config))) - if CONF_FIELD_STRENGTH_X in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X]) - cg.add(var.set_x_sensor(sens)) - if CONF_FIELD_STRENGTH_Y in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y]) - cg.add(var.set_y_sensor(sens)) - if CONF_FIELD_STRENGTH_Z in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z]) - cg.add(var.set_z_sensor(sens)) - if CONF_HEADING in config: - sens = await sensor.new_sensor(config[CONF_HEADING]) - cg.add(var.set_heading_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) + await sensors(CONF_HEADING, var.set_heading_sensor) if CONF_AUTO_SET_RESET in config: cg.add(var.set_auto_set_reset(config[CONF_AUTO_SET_RESET])) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed706..fe937587261 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -7,13 +8,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - CONF_CONTINUOUS, - CONF_DISABLE_CRC, - CONF_FLOW_CONTROL_PIN, - CONF_ID, -) +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv @@ -48,6 +43,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +53,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 +# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +83,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +134,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 -# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +225,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: @@ -165,10 +279,6 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Optional( CONF_TURNAROUND_TIME, default="600ms" ): cv.positive_time_period_milliseconds, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -177,10 +287,6 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusServer), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a821..5f237be8dd7 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -203,8 +203,14 @@ void ModbusClientHub::parse_modbus_frames() { void ModbusServerHub::parse_modbus_frames() { while (!this->rx_buffer_.empty()) { + if (this->deferred_payload_len_ != 0) { + // Another frame arrived before the deferred reply went out, so the client has moved on. + this->cancel_timeout("deferred_send"); + ESP_LOGD(TAG, "Dropped deferred reply to %" PRIu8 ": a new frame arrived first", this->deferred_payload_[0]); + this->deferred_payload_len_ = 0; + } size_t size = this->rx_buffer_.size(); - ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); + ESP_LOGVV(TAG, "Parsing frames buffer size = %zu", size); bool retry_as_client = false; // A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex). const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS; @@ -832,7 +838,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1080,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1087,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1145,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } @@ -1199,6 +1219,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); + this->deferred_payload_len_ = 0; if (!this->send_frame_(frame)) { ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked"); } @@ -1220,6 +1241,11 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t if (warn) { ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), micros() - this->last_send_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; + ESP_LOGV(TAG, " discarded: %s%s", format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), bytes), + bytes > MODBUS_MAX_LOG_BYTES ? LOG_STR_LITERAL(" ...") : LOG_STR_LITERAL("")); +#endif } else { ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), micros() - this->last_send_); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d5..298cd9f5278 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -243,14 +252,6 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->queue_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); - }; /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. /// Neither means anything reached the wire - on_sent() reports that. @@ -534,27 +535,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb910664..66ddcd7722d 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9b..4c1d11da838 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e3..aa72a08a60b 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb0..b8d06d3d5a3 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31e..d21b3194358 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; #pragma GCC diagnostic pop -// Remove before 2026.10.0 — these helpers have moved to modbus::helpers -ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") -inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } - -ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_read_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_write_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } - -ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::byte_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::word_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::dword_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::qword_from_hex_str(value, pos); -} - -template -ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") -T get_data(const std::vector &data, size_t buffer_offset) { - return modbus::helpers::get_data(data, buffer_offset); -} - -// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a // std::span (previously a const std::vector &), and a span does not convert to // a vector, so existing lambdas calling these by name need an overload that accepts one. These carry -// this release's deprecation window, since the span forms only exist from it. +// the 2026.8.0 deprecation window, since the span forms only exist from it. // payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers // type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here // would only make that call ambiguous. @@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span data) { return modbus::helpers::bit_from_packed(coil, data); } -template -ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") -N mask_and_shift_by_rightbit(N data, uint32_t mask) { - return modbus::helpers::mask_and_shift_by_rightbit(data, mask); -} - -ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - modbus::helpers::number_to_payload(data, value, value_type); -} - -ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") -inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) - .value_or(0); -} - -ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline std::vector float_to_payload(float value, SensorValueType value_type) { - std::vector data; - modbus::helpers::float_to_payload(data, value, value_type); - return data; -} - -class ModbusController; - /// How an item relates to the register range built just before it (same register type, address order). /// The numeric order doubles as the comparator tiebreak for items at the same address (see /// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and @@ -280,10 +210,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +236,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +259,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af7..242e2eea218 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index aff05cd517a..223aa12bec2 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Number new state : %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d74..c964ced987b 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab6..6fc8c8331cf 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index b2bc2b5fd04..2035f2220a3 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Sensor new state: %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a31..2c5b92b810b 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 7bf45366c0a..855a7b28c30 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -16,17 +16,11 @@ void ModbusSwitch::setup() { optional initial_state = Switch::get_initial_state_with_restore_mode(); if (initial_state.has_value()) { // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } -void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } - bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(std::span data) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 688a620bac1..b98543532e6 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -31,7 +31,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void setup() override; void write_state(bool state) override; void dump_config() override; - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_state(bool state) { this->state = state; } void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->set_controller_(parent); } diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 5f7c9445f21..761e5831c11 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -159,21 +159,10 @@ async def to_code(config: ConfigType) -> None: ) is not None: cg.add(var.set_min_signal_quality(minimum_signal_quality)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_LEVEL]) - cg.add(var.set_level(sens)) - if CONF_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_DISTANCE]) - cg.add(var.set_distance(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_SIGNAL_QUALITY in config: - sens = await sensor.new_sensor(config[CONF_SIGNAL_QUALITY]) - cg.add(var.set_signal_quality(sens)) - if CONF_IGNORED_READS in config: - sens = await sensor.new_sensor(config[CONF_IGNORED_READS]) - cg.add(var.set_ignored_reads(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_LEVEL, var.set_level) + await sensors(CONF_DISTANCE, var.set_distance) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_SIGNAL_QUALITY, var.set_signal_quality) + await sensors(CONF_IGNORED_READS, var.set_ignored_reads) diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index d5c5d4135d6..b3e55766d8a 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -128,15 +128,8 @@ async def to_code(config: ConfigType) -> None: if CONF_PROPANE_BUTANE_MIX in config: cg.add(var.set_propane_butane_mix(config[CONF_PROPANE_BUTANE_MIX])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_LEVEL]) - cg.add(var.set_level(sens)) - if CONF_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_DISTANCE]) - cg.add(var.set_distance(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_LEVEL, var.set_level) + await sensors(CONF_DISTANCE, var.set_distance) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py index aea052fa2f1..63a104f1a7e 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -25,7 +25,6 @@ CONF_TRANSFORM_MATRIX = "transform_matrix" CalibrateLevelAction = motion_ns.class_("CalibrateLevelAction", automation.Action) CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.Action) -ClearCalibrationAction = motion_ns.class_("ClearCalibrationAction", automation.Action) KEY_ACCELEROMETER = "accelerometer" KEY_GYROSCOPE = "gyroscope" @@ -207,15 +206,8 @@ CLEAR_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "motion.clear_calibration", - ClearCalibrationAction, CLEAR_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SAVE, "clear_calibration", cg.bool_), ) -async def clear_calibration_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - if config.get(CONF_SAVE): - cg.add(var.set_save(True)) - return var diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp index 8715c8385c9..226dc94e638 100644 --- a/esphome/components/motion/motion_component.cpp +++ b/esphome/components/motion/motion_component.cpp @@ -56,10 +56,13 @@ bool MotionComponent::save_calibration() { ESP_LOGW(TAG, "Calibration save failed"); return false; } -void MotionComponent::clear_calibration() { +void MotionComponent::clear_calibration(bool save) { memcpy(this->matrix_, this->base_matrix_, sizeof(this->matrix_)); ESP_LOGI(TAG, "Calibration reset to build-time matrix"); log_matrix(this->matrix_); + if (save) { + this->save_calibration(); + } } void MotionComponent::update() { if (this->is_failed()) diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index b0a074a17c2..d117ca00320 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -52,8 +52,9 @@ class MotionComponent : public PollingComponent { bool calibrate_heading(); /// Save the current matrix to NVS. bool save_calibration(); - /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration. - void clear_calibration(); + /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration, + /// and persist that base when `save` is set. + void clear_calibration(bool save = false); template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } @@ -135,20 +136,4 @@ template class CalibrateHeadingAction final : public Action class ClearCalibrationAction final : public Action { - public: - explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} - void set_save(bool save) { this->save_ = save; } - - protected: - void play(const Ts &...) override { - this->parent_->clear_calibration(); - if (this->save_) - this->parent_->save_calibration(); - } - - MotionComponent *parent_; - bool save_{false}; -}; - } // namespace esphome::motion diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 3050ceb1a4b..b6badb4ef96 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components import logger, socket from esphome.components.esp32 import ( @@ -118,10 +117,7 @@ mqtt_ns = cg.esphome_ns.namespace("mqtt") MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) -MQTTPublishAction = mqtt_ns.class_("MQTTPublishAction", automation.Action) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTEnableAction = mqtt_ns.class_("MQTTEnableAction", automation.Action) -MQTTDisableAction = mqtt_ns.class_("MQTTDisableAction", automation.Action) MQTTMessageTrigger = mqtt_ns.class_( "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component ) @@ -135,7 +131,6 @@ MQTTDisconnectTrigger = mqtt_ns.class_( "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) ) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) -MQTTConnectedCondition = mqtt_ns.class_("MQTTConnectedCondition", Condition) MQTTAlarmControlPanelComponent = mqtt_ns.class_( "MQTTAlarmControlPanelComponent", MQTTComponent @@ -248,8 +243,8 @@ CONFIG_SCHEMA = cv.All( cv.Inclusive(CONF_CLIENT_CERTIFICATE, "cert-key-pair"): cv.All( cv.string, cv.only_on_esp32 ), - cv.Inclusive(CONF_CLIENT_CERTIFICATE_KEY, "cert-key-pair"): cv.All( - cv.string, cv.only_on_esp32 + cv.Inclusive(CONF_CLIENT_CERTIFICATE_KEY, "cert-key-pair"): cv.sensitive( + cv.All(cv.string, cv.only_on_esp32) ), cv.SplitDefault(CONF_SKIP_CERT_CN_CHECK, esp32=False): cv.All( cv.boolean, cv.only_on_esp32 @@ -505,22 +500,26 @@ MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, synchronous=True -) -async def mqtt_publish_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TOPIC], args, cg.std_string) - cg.add(var.set_topic(template_)) +# A bare literal is ambiguous between the std::string and (const char *, size_t) publish +# overloads, so constants and inlined `return "...";` lambdas are both spelled as std::string. +def _std_string(config: ConfigType, value: str) -> str: + rendered = automation.flash_string(config, value) + return rendered if CORE.is_esp8266 else f"std::string({rendered})" - template_ = await cg.templatable(config[CONF_PAYLOAD], args, cg.std_string) - cg.add(var.set_payload(template_)) - template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) - cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) - cg.add(var.set_retain(template_)) - return var + +automation.register_apply_action( + "mqtt.publish", + MQTT_PUBLISH_ACTION_SCHEMA, + automation.ApplyCall( + "publish({}, {}, {}, {})", + ( + (CONF_TOPIC, "std::string", _std_string), + (CONF_PAYLOAD, "std::string", _std_string), + (CONF_QOS, cg.uint8), + (CONF_RETAIN, cg.bool_), + ), + ), +) MQTT_PUBLISH_JSON_ACTION_SCHEMA = cv.Schema( @@ -599,48 +598,27 @@ async def register_mqtt_component(var, config): ) -@automation.register_condition( +automation.register_apply_condition( "mqtt.connected", - MQTTConnectedCondition, cv.Schema( { cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + "is_connected()", ) -async def mqtt_connected_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) -@automation.register_action( - "mqtt.enable", - MQTTEnableAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(MQTTClientComponent), - } - ), - synchronous=True, -) -async def mqtt_enable_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "mqtt.disable", - MQTTDisableAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(MQTTClientComponent), - } - ), - synchronous=True, -) -async def mqtt_disable_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in (("mqtt.enable", "enable()"), ("mqtt.disable", "disable()")): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.GenerateID(): cv.use_id(MQTTClientComponent), + } + ), + automation.ApplyCall(_call), + ) _platform_filter = filter_source_files_from_platform( diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 09eb5f97dce..791e5504965 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -108,7 +108,7 @@ void MQTTBackendESP32::loop() { if ((now - this->last_dropped_log_time_) >= DROP_LOG_INTERVAL_MS) { uint16_t dropped = this->mqtt_queue_.get_and_reset_dropped_count(); if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %u messages (%us)", dropped, DROP_LOG_INTERVAL_MS / 1000); + ESP_LOGW(TAG, "Dropped %u messages (%" PRIu32 "s)", dropped, DROP_LOG_INTERVAL_MS / 1000); } this->last_dropped_log_time_ = now; } diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index fe0966e7255..ced9c84e100 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -378,23 +378,6 @@ class MQTTDisconnectTrigger final : public Trigger { } }; -template class MQTTPublishAction final : public Action { - public: - MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, topic) - TEMPLATABLE_VALUE(std::string, payload) - TEMPLATABLE_VALUE(uint8_t, qos) - TEMPLATABLE_VALUE(bool, retain) - - void play(const Ts &...x) override { - this->parent_->publish(this->topic_.value(x...), this->payload_.value(x...), this->qos_.value(x...), - this->retain_.value(x...)); - } - - protected: - MQTTClientComponent *parent_; -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -417,35 +400,6 @@ template class MQTTPublishJsonAction final : public Action class MQTTConnectedCondition final : public Condition { - public: - MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} - bool check(const Ts &...x) override { return this->parent_->is_connected(); } - - protected: - MQTTClientComponent *parent_; -}; - -template class MQTTEnableAction final : public Action { - public: - MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->enable(); } - - protected: - MQTTClientComponent *parent_; -}; - -template class MQTTDisableAction final : public Action { - public: - MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->disable(); } - - protected: - MQTTClientComponent *parent_; -}; - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index a80cea6bd69..59a5d02d977 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -312,11 +312,7 @@ bool MQTTComponent::send_discovery_() { // Buffer sized for format string expansion: ~4 bytes net growth from format specifier to 8 hex digits, plus // safety margin char version_buf[sizeof(ver_fmt) + 8]; -#ifdef USE_ESP8266 - snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); -#else - snprintf(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); -#endif + ESPHOME_snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); device_info[MQTT_DEVICE_SW_VERSION] = version_buf; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index aa47bdf996a..a8b52a3839f 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -67,6 +67,9 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE)) color_modes.add(ESPHOME_F("rgbww")); + if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) + root[ESPHOME_F("brightness")] = true; + if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) || traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) { root[MQTT_MIN_MIREDS] = traits.get_min_mireds(); diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 7920187f922..369c1c6668b 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -50,14 +50,8 @@ bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; -#ifdef USE_STORE_LOG_STR_IN_FLASH - char buf[LOCK_STATE_STR_SIZE]; - strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); - buf[sizeof(buf) - 1] = '\0'; - return this->publish(this->get_state_topic_to_(topic_buf), buf); -#else - return this->publish(this->get_state_topic_to_(topic_buf), LOG_STR_ARG(lock_state_to_string(this->lock_->state))); -#endif + return this->publish(this->get_state_topic_to_(topic_buf), + reinterpret_cast(lock_state_to_string(this->lock_->state))); } } // namespace esphome::mqtt diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 1c0625d1c9e..a601a9bcc89 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -1,6 +1,7 @@ #include #include "mqtt_sensor.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "mqtt_const.h" @@ -59,11 +60,7 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { -#ifdef USE_STORE_LOG_STR_IN_FLASH - root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); -#else - root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); -#endif + root[MQTT_STATE_CLASS] = reinterpret_cast(state_class_to_string(this->sensor_->get_state_class())); } config.command_topic = false; diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index 40b5b46e1d2..afb725feb64 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -25,7 +25,6 @@ void MQTTSubscribeSensor::setup() { } float MQTTSubscribeSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeSensor::dump_config() { LOG_SENSOR("", "MQTT Subscribe", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 739e8456ee8..b0a8a0a78a2 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -18,7 +18,7 @@ class MQTTSubscribeSensor final : public sensor::Sensor, public Component { void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index edc197671e6..470e08d59ae 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -15,7 +15,6 @@ void MQTTSubscribeTextSensor::setup() { this->qos_); } float MQTTSubscribeTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeTextSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "MQTT Subscribe Text Sensor", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index 8641825fca9..dc02eb5d187 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -17,7 +17,7 @@ class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Com void setup() override; void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index c53a018234d..e31070e0a39 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -96,21 +95,4 @@ class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, publ void set_calibration_failure_(bool failed); }; -template -class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } -}; - -template -class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } -}; - -template class NAU7802CalbrateGainAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_gain(); } -}; - } // namespace esphome::nau7802 diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 415ae09dafa..2647d40441a 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, ICON_SCALE, STATE_CLASS_MEASUREMENT -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@cujomalainey"] @@ -20,19 +18,6 @@ nau7802_ns = cg.esphome_ns.namespace("nau7802") NAU7802Sensor = nau7802_ns.class_( "NAU7802Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -NAU7802CalbrateExternalOffsetAction = nau7802_ns.class_( - "NAU7802CalbrateExternalOffsetAction", - automation.Action, - cg.Parented.template(NAU7802Sensor), -) -NAU7802CalbrateInternalOffsetAction = nau7802_ns.class_( - "NAU7802CalbrateInternalOffsetAction", - automation.Action, - cg.Parented.template(NAU7802Sensor), -) -NAU7802CalbrateGainAction = nau7802_ns.class_( - "NAU7802CalbrateGainAction", automation.Action, cg.Parented.template(NAU7802Sensor) -) NAU7802Gain = nau7802_ns.enum("NAU7802Gain") GAINS = { @@ -116,30 +101,11 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "nau7802.calibrate_internal_offset", - NAU7802CalbrateInternalOffsetAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) -@automation.register_action( - "nau7802.calibrate_external_offset", - NAU7802CalbrateExternalOffsetAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) -@automation.register_action( - "nau7802.calibrate_gain", - NAU7802CalbrateGainAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) -async def nau7802_calibrate_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]) - return var +for _name, _call in ( + ("nau7802.calibrate_internal_offset", "calibrate_internal_offset()"), + ("nau7802.calibrate_external_offset", "calibrate_external_offset()"), + ("nau7802.calibrate_gain", "calibrate_gain()"), +): + automation.register_apply_action( + _name, NAU7802_CALIBRATE_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 9a47679ba3c..decee27bcf8 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -9,9 +9,11 @@ from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( CONF_ENABLE_IPV6, + CONF_ETHERNET, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT, CONF_PRIORITY, + CONF_WIFI, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv @@ -28,6 +30,16 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" CONF_TCP_SEND_BUFFER = "tcp_send_buffer" +# TCP receive window of the optimized lwip tier (PSRAM not guaranteed) +TCP_WND_OPTIMIZED = 65534 +# Ethernet drivers keep every queued frame in internal RAM, so ethernet-only builds +# get a window sized for a LAN round trip and the stock lwip input mailbox. Dual +# wifi + ethernet builds keep wifi's sizes; the ethernet component moves their +# received frames to PSRAM instead. +TCP_WND_ETHERNET = 16384 +TCPIP_RECVMBOX_ETHERNET = 32 +TCPIP_RECVMBOX_OPTIMIZED = 64 + # lwIP queues at most this many unsent/unacked bytes per TCP socket; the # stock ESP-IDF default (5744 bytes) stalls bursty senders like a Bluetooth # proxy streaming GATT notifications. Bounds follow the lwIP guidance for the @@ -166,6 +178,8 @@ def require_high_performance_networking() -> None: Configuration is PSRAM-aware: - With PSRAM guaranteed: Aggressive settings (512 RX buffers, 512KB TCP windows) - Without PSRAM: Conservative optimized settings (64 buffers, 65KB TCP windows) + - Ethernet only: 16KB TCP windows regardless of PSRAM, because ESP-IDF + ethernet drivers keep received frames in internal RAM Example: from esphome.components import network @@ -417,8 +431,11 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32 and should_enable: # Check if PSRAM is guaranteed (set by psram component during final validation) psram_guaranteed = psram_is_guaranteed() + # ESP-IDF ethernet drivers malloc() received frames into internal RAM, so + # lwip is never sized for PSRAM on an ethernet-only build. + ethernet_only = CONF_ETHERNET in CORE.config and CONF_WIFI not in CORE.config - if psram_guaranteed: + if psram_guaranteed and not ethernet_only: _LOGGER.info( "Applying high-performance lwip settings (PSRAM guaranteed): 512KB TCP windows, 512 mailbox sizes" ) @@ -451,15 +468,25 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_TCP_OVERSIZE_MSS", True) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_QUEUE_OOSEQ", True) else: + # Every queued byte is internal RAM on ethernet and a LAN round trip needs + # little window; wifi keeps the larger sizes. + if ethernet_only: + tcp_window, tcpip_mailbox = TCP_WND_ETHERNET, TCPIP_RECVMBOX_ETHERNET + else: + tcp_window, tcpip_mailbox = TCP_WND_OPTIMIZED, TCPIP_RECVMBOX_OPTIMIZED _LOGGER.info( - "Applying optimized lwip settings: 65KB TCP windows, 64 mailbox sizes" + "Applying optimized lwip settings: %dKB TCP windows, %d entry input mailbox", + tcp_window // 1000, + tcpip_mailbox, ) # PSRAM not guaranteed - use more conservative, but still optimized settings # Based on https://github.com/espressif/esp-idf/blob/release/v5.4/examples/wifi/iperf/sdkconfig.defaults.esp32 + # The send buffer stays at 65534 on ethernet too: it only fills under outbound + # load, and bursty senders such as a Bluetooth proxy stall on the IDF default. add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT", 65534) - add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", 65534) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) - add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) + add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox) # After the high performance block so an explicit size wins over the # bundle's 65534 (last write wins in the sdkconfig store). diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 28f83cc4fa4..3b0621d1f31 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -147,15 +147,6 @@ struct IPAddress { } return (ntohl(this->ip_addr_.u_addr.ip4.s_addr) & 0xF0000000UL) == 0xE0000000UL; } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } char *str_to(char *buf) const { if (this->ip_addr_.type == IPADDR_TYPE_V6) { inet_ntop(AF_INET6, &this->ip_addr_.u_addr.ip6, buf, IP_ADDRESS_BUFFER_SIZE); diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 65a578c22ff..57c5a66833b 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -26,30 +26,34 @@ namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) ESPHOME_ALWAYS_INLINE inline bool is_connected() { + // With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which + // clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked. + // NOLINTBEGIN(readability-simplify-boolean-expr) #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) return true; #endif #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); + if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected()) + return true; #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) + return true; #endif #ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); + if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) + return true; #endif #ifdef USE_HOST return true; // Assume it's connected #endif return false; + // NOLINTEND(readability-simplify-boolean-expr) } /// Return whether the network is disabled: every configured interface with a diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h deleted file mode 100644 index 0226c65be6c..00000000000 --- a/esphome/components/nextion/automation.h +++ /dev/null @@ -1,96 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/string_ref.h" - -#include "nextion.h" - -namespace esphome::nextion { - -template class NextionSetBrightnessAction final : public Action { - public: - explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} - - TEMPLATABLE_VALUE(float, brightness) - - void play(const Ts &...x) override { - this->component_->set_brightness(this->brightness_.value(x...)); - this->component_->set_backlight_brightness(this->brightness_.value(x...)); - } - - void set_brightness(std::function brightness) { this->brightness_ = brightness; } - - protected: - Nextion *component_; -}; - -template class NextionPublishFloatAction final : public Action { - public: - explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(float, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -template class NextionPublishTextAction final : public Action { - public: - explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(const char *, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -template class NextionPublishBoolAction final : public Action { - public: - explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -} // namespace esphome::nextion diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 29f5bdaea78..6c390e1c6e2 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -18,10 +18,6 @@ NextionBinarySensor = nextion_ns.class_( "NextionBinarySensor", binary_sensor.BinarySensor, cg.PollingComponent ) -NextionPublishBoolAction = nextion_ns.class_( - "NextionPublishBoolAction", automation.Action -) - CONFIG_SCHEMA = cv.All( binary_sensor.binary_sensor_schema(NextionBinarySensor) .extend( @@ -57,9 +53,8 @@ async def to_code(config): cg.add(hub.register_binarysensor_component(var)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.nextion.publish", - NextionPublishBoolAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionBinarySensor), @@ -70,19 +65,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.bool_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3f5ba94b403..73a56e0a42b 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -14,12 +14,7 @@ from esphome.const import ( ) from esphome.core import CORE, TimePeriod -from . import ( # noqa: F401 pylint: disable=unused-import - FILTER_SOURCE_FILES, - Nextion, - nextion_ns, - nextion_ref, -) +from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( CONF_AUTO_WAKE_ON_TOUCH, CONF_COMMAND_SPACING, @@ -61,11 +56,6 @@ def AUTO_LOAD() -> list[str]: return base -NextionSetBrightnessAction = nextion_ns.class_( - "NextionSetBrightnessAction", automation.Action -) - - def _deprecated_dump_device_info(value): _LOGGER.warning( "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " @@ -165,9 +155,8 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "display.nextion.set_brightness", - NextionSetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Nextion), @@ -175,16 +164,9 @@ CONFIG_SCHEMA = cv.All( }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_BRIGHTNESS, "set_backlight_brightness", cg.float_), ) -async def nextion_set_brightness_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.float_) - cg.add(var.set_brightness(template_)) - - return var _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97910ba3d55..625c915e732 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -13,6 +13,11 @@ namespace esphome::nextion { static const char *const TAG = "nextion"; +// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag +static bool is_sleep_wake_command(const NextionComponentBase *component) { + return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake"; +} + // Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); @@ -163,6 +168,17 @@ bool Nextion::check_connect_() { #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE } +// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and +// component storage comes from RAMAllocator, so delete is not valid for either. +void Nextion::release_queue_entry_(NextionQueue *nb) { + if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) { + nb->component->~NextionComponentBase(); + RAMAllocator().deallocate(nb->component, 1); + } + nb->~NextionQueue(); + RAMAllocator().deallocate(nb, 1); +} + void Nextion::reset_(bool reset_nextion) { uint8_t d; @@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) { this->read_byte(&d); } for (auto *entry : this->nextion_queue_) { - if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { - delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) - } - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->nextion_queue_.clear(); #ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->waveform_queue_.clear(); #endif // USE_NEXTION_WAVEFORM @@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return false; } @@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) { ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); return true; } @@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); } #else // USE_NEXTION_WAVEFORM @@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue entry"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_string(to_process, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_int(value, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); component->clear_wave_buffer(buffer_to_send); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); #else // USE_NEXTION_WAVEFORM ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); @@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() { ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - - delete *it; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(*it); it = this->nextion_queue_.erase(it); } else { @@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool return response.length(); } +// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out +NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) { +#ifdef USE_NEXTION_MAX_QUEUE_SIZE + if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { + ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + return nullptr; + } +#endif + + auto *nextion_queue = RAMAllocator().allocate(1); + if (nextion_queue == nullptr) { + ESP_LOGW(TAG, "Queue alloc failed"); + return nullptr; + } + new (nextion_queue) nextion::NextionQueue; + + nextion_queue->component = RAMAllocator().allocate(1); + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + this->release_queue_entry_(nextion_queue); + return nullptr; + } + new (nextion_queue->component) nextion::NextionComponentBase; + nextion_queue->component->set_variable_name(variable_name); + nextion_queue->queue_time = App.get_loop_component_start_time(); + return nextion_queue; +} + /** * @brief Add a command to the Nextion queue that expects no response. * @@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool * @param variable_name Name of the variable or component associated with the command. */ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - - nextion_queue->queue_time = App.get_loop_component_start_time(); - this->nextion_queue_.push_back(nextion_queue); - - ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str()); } /** @@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name, const std::string &command) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry - this->nextion_queue_.push_back(nextion_queue); ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str()); } @@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); } else { - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); } #endif // USE_NEXTION_COMMAND_SPACING } @@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); if (!this->waveform_queue_.push(nextion_queue)) { ESP_LOGW(TAG, "Waveform queue full, drop"); - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); return; } if (this->waveform_queue_.size() == 1) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index aa9fe8abb3f..320aec048a0 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -614,7 +614,6 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: */ void disable_component_touch(const char *component); -#ifdef USE_NEXTION_WAVEFORM /** * Add waveform data to a waveform component * @param component_id The integer component id. @@ -624,7 +623,6 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value); void open_waveform_channel(uint8_t component_id, uint8_t channel_number, uint8_t value); -#endif // USE_NEXTION_WAVEFORM /** * Display a picture at coordinates. @@ -1469,6 +1467,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; bool remove_from_q_(bool report_empty = true); + void release_queue_entry_(NextionQueue *nb); + NextionQueue *make_no_result_entry_(const std::string &variable_name); /** * @brief Status flags for Nextion display state management diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a356d54e2f1..dd3a97065cb 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -217,7 +217,6 @@ void Nextion::set_component_value(const char *component, int32_t value) { this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } -#ifdef USE_NEXTION_WAVEFORM void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); @@ -227,7 +226,6 @@ void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); } -#endif // USE_NEXTION_WAVEFORM void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 6676d019201..b66c0b9e4e0 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -23,8 +23,7 @@ class NextionComponentBase; class NextionQueue { public: - virtual ~NextionQueue() = default; - NextionComponentBase *component; + NextionComponentBase *component{nullptr}; uint32_t queue_time = 0; // Store command for retry if spacing blocked it @@ -67,6 +66,7 @@ class NextionComponentBase { #ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } const std::vector &get_wave_buffer() const { return this->wave_buffer_; } size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } @@ -87,12 +87,6 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } -#ifdef USE_NEXTION_WAVEFORM - // Remove before 2026.10.0 - ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") - uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } - void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } -#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; @@ -105,6 +99,6 @@ class NextionComponentBase { int wave_max_length_ = 255; #endif // USE_NEXTION_WAVEFORM - bool needs_to_send_update_; + bool needs_to_send_update_{false}; }; } // namespace esphome::nextion diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 61cb42e62cb..6c93286656a 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -21,10 +21,6 @@ CODEOWNERS = ["@senexcrenshaw"] NextionSensor = nextion_ns.class_("NextionSensor", sensor.Sensor, cg.PollingComponent) -NextionPublishFloatAction = nextion_ns.class_( - "NextionPublishFloatAction", automation.Action -) - def CheckWaveID(value): value = cv.int_(value) @@ -97,9 +93,8 @@ async def to_code(config): cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) -@automation.register_action( +automation.register_apply_action( "sensor.nextion.publish", - NextionPublishFloatAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionSensor), @@ -110,19 +105,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.float_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 29749ecab02..5053c30eb39 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -16,10 +16,6 @@ CODEOWNERS = ["@senexcrenshaw"] NextionSwitch = nextion_ns.class_("NextionSwitch", switch.Switch, cg.PollingComponent) -NextionPublishBoolAction = nextion_ns.class_( - "NextionPublishBoolAction", automation.Action -) - CONFIG_SCHEMA = cv.All( switch.switch_schema(NextionSwitch) .extend(CONFIG_SWITCH_COMPONENT_SCHEMA) @@ -39,9 +35,8 @@ async def to_code(config): await setup_component_core_(var, config, ".val") -@automation.register_action( +automation.register_apply_action( "switch.nextion.publish", - NextionPublishBoolAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionSwitch), @@ -52,19 +47,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.bool_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/text_sensor/__init__.py b/esphome/components/nextion/text_sensor/__init__.py index 168a6724979..b8fd0c8fdab 100644 --- a/esphome/components/nextion/text_sensor/__init__.py +++ b/esphome/components/nextion/text_sensor/__init__.py @@ -13,10 +13,6 @@ NextionTextSensor = nextion_ns.class_( "NextionTextSensor", text_sensor.TextSensor, cg.PollingComponent ) -NextionPublishTextAction = nextion_ns.class_( - "NextionPublishTextAction", automation.Action -) - CONFIG_SCHEMA = ( text_sensor.text_sensor_schema(NextionTextSensor) .extend(CONFIG_TEXT_COMPONENT_SCHEMA) @@ -35,9 +31,8 @@ async def to_code(config): await setup_component_core_(var, config, ".txt") -@automation.register_action( +automation.register_apply_action( "text_sensor.nextion.publish", - NextionPublishTextAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionTextSensor), @@ -48,19 +43,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.std_string), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.const_char_ptr) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nfc/__init__.py b/esphome/components/nfc/__init__.py index eea1a47b24b..6afc2eca092 100644 --- a/esphome/components/nfc/__init__.py +++ b/esphome/components/nfc/__init__.py @@ -7,7 +7,8 @@ nfc_ns = cg.esphome_ns.namespace("nfc") Nfcc = nfc_ns.class_("Nfcc") NfcTag = nfc_ns.class_("NfcTag") +NfcTagConstRef = NfcTag.operator("ref").operator("const") NfcTagListener = nfc_ns.class_("NfcTagListener") NfcOnTagTrigger = nfc_ns.class_( - "NfcOnTagTrigger", automation.Trigger.template(cg.std_string, NfcTag) + "NfcOnTagTrigger", automation.Trigger.template(cg.std_string, NfcTagConstRef) ) diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index ec3a979b647..6e03430d6fa 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -7,7 +7,8 @@ namespace esphome::nfc { -class NfcOnTagTrigger final : public Trigger { +/// Fires with the formatted UID and the tag itself; the tag is passed by reference so no copy is made per trigger +class NfcOnTagTrigger final : public Trigger { public: void process(const std::unique_ptr &tag); }; diff --git a/esphome/components/nfc/binary_sensor/__init__.py b/esphome/components/nfc/binary_sensor/__init__.py index e9747d7a142..2fde6804da9 100644 --- a/esphome/components/nfc/binary_sensor/__init__.py +++ b/esphome/components/nfc/binary_sensor/__init__.py @@ -20,7 +20,6 @@ NfcTagBinarySensor = nfc_ns.class_( binary_sensor.BinarySensor, cg.Component, NfcTagListener, - cg.Parented.template(Nfcc), ) @@ -59,12 +58,15 @@ CONFIG_SCHEMA = cv.All( ) +_request_listener_slot = cg.slot_counter("NFC_TAG_LISTENER_COUNT") + + async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await cg.register_parented(var, config[CONF_NFCC_ID]) hub = await cg.get_variable(config[CONF_NFCC_ID]) + _request_listener_slot(str(hub)) cg.add(hub.register_listener(var)) if CONF_NDEF_CONTAINS in config: cg.add(var.set_ndef_match_string(config[CONF_NDEF_CONTAINS])) diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp index 6e8162fc912..995255d1e45 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -7,10 +7,7 @@ namespace esphome::nfc { static const char *const TAG = "nfc.binary_sensor"; -void NfcTagBinarySensor::setup() { - this->parent_->register_listener(this); - this->publish_initial_state(false); -} +void NfcTagBinarySensor::setup() { this->publish_initial_state(false); } void NfcTagBinarySensor::dump_config() { std::string match_str = "name"; diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index 6354e169670..f5def158348 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -8,10 +8,7 @@ namespace esphome::nfc { -class NfcTagBinarySensor final : public binary_sensor::BinarySensor, - public Component, - public NfcTagListener, - public Parented { +class NfcTagBinarySensor final : public binary_sensor::BinarySensor, public Component, public NfcTagListener { public: void setup() override; void dump_config() override; diff --git a/esphome/components/nfc/nci_message.cpp b/esphome/components/nfc/nci_message.cpp index 0b60fd0aded..d00c8af5ea7 100644 --- a/esphome/components/nfc/nci_message.cpp +++ b/esphome/components/nfc/nci_message.cpp @@ -2,56 +2,64 @@ #include "nci_message.h" #include "esphome/core/log.h" -#include - namespace esphome::nfc { -static const char *const TAG = "NciMessage"; - -NciMessage::NciMessage(const uint8_t message_type, const std::vector &payload) { +NciMessage::NciMessage(const uint8_t message_type, const std::span payload) { this->set_message(message_type, payload); } +NciMessage::NciMessage(const uint8_t message_type, const std::initializer_list payload) + : NciMessage(message_type, std::span(payload.begin(), payload.size())) {} + NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid) { + this->reset(); this->set_header(message_type, gid, oid); } NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid, - const std::vector &payload) { + const std::span payload) { this->set_message(message_type, gid, oid, payload); } -NciMessage::NciMessage(const std::vector &raw_packet) { this->nci_message_ = raw_packet; }; +NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid, + const std::initializer_list payload) + : NciMessage(message_type, gid, oid, std::span(payload.begin(), payload.size())) {} -std::vector NciMessage::encode() { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; - std::vector message = this->nci_message_; - return message; +NciMessage::NciMessage(const std::span raw_packet) { + this->nci_message_.assign(raw_packet.begin(), raw_packet.end()); } -void NciMessage::reset() { this->nci_message_ = {0, 0, 0}; } - -uint8_t NciMessage::get_message_type() const { - return this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_MT_MASK; +std::span NciMessage::encode() { + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; + return this->nci_message_; } -uint8_t NciMessage::get_gid() const { return this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_GID_MASK; } +void NciMessage::reset() { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = 0; + this->nci_message_[NCI_PKT_OID_OFFSET] = 0; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = 0; +} -uint8_t NciMessage::get_oid() const { return this->nci_message_[nfc::NCI_PKT_OID_OFFSET] & nfc::NCI_PKT_OID_MASK; } +uint8_t NciMessage::get_message_type() const { return this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_MT_MASK; } + +uint8_t NciMessage::get_gid() const { return this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_GID_MASK; } + +uint8_t NciMessage::get_oid() const { return this->nci_message_[NCI_PKT_OID_OFFSET] & NCI_PKT_OID_MASK; } uint8_t NciMessage::get_payload_size(const bool recompute) { if (!this->nci_message_.empty()) { if (recompute) { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } - return this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET]; + return this->nci_message_[NCI_PKT_LENGTH_OFFSET]; } return 0; } uint8_t NciMessage::get_simple_status_response() const { - if (this->nci_message_.size() > nfc::NCI_PKT_PAYLOAD_OFFSET) { - return this->nci_message_[nfc::NCI_PKT_PAYLOAD_OFFSET]; + if (this->nci_message_.size() > NCI_PKT_PAYLOAD_OFFSET) { + return this->nci_message_[NCI_PKT_PAYLOAD_OFFSET]; } return STATUS_FAILED; } @@ -63,102 +71,112 @@ uint8_t NciMessage::get_message_byte(const uint8_t offset) const { return 0; } -std::vector &NciMessage::get_message() { return this->nci_message_; } +std::span NciMessage::get_payload() const { + if (this->nci_message_.size() <= NCI_PKT_HEADER_SIZE) { + return {}; + } + return {this->nci_message_.data() + NCI_PKT_HEADER_SIZE, this->nci_message_.size() - NCI_PKT_HEADER_SIZE}; +} -bool NciMessage::has_payload() const { return this->nci_message_.size() > nfc::NCI_PKT_HEADER_SIZE; } +bool NciMessage::has_payload() const { return this->nci_message_.size() > NCI_PKT_HEADER_SIZE; } bool NciMessage::message_type_is(const uint8_t message_type) const { if (!this->nci_message_.empty()) { - return message_type == (this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_MT_MASK); + return message_type == (this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_MT_MASK); } return false; } bool NciMessage::message_length_is(const uint8_t message_length, const bool recompute) { - if (this->nci_message_.size() > nfc::NCI_PKT_LENGTH_OFFSET) { + if (this->nci_message_.size() > NCI_PKT_LENGTH_OFFSET) { if (recompute) { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } - return message_length == this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET]; + return message_length == this->nci_message_[NCI_PKT_LENGTH_OFFSET]; } return false; } bool NciMessage::gid_is(const uint8_t gid) const { - if (this->nci_message_.size() > nfc::NCI_PKT_MT_GID_OFFSET) { - return gid == (this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_GID_MASK); + if (this->nci_message_.size() > NCI_PKT_MT_GID_OFFSET) { + return gid == (this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_GID_MASK); } return false; } bool NciMessage::oid_is(const uint8_t oid) const { - if (this->nci_message_.size() > nfc::NCI_PKT_OID_OFFSET) { - return oid == (this->nci_message_[nfc::NCI_PKT_OID_OFFSET] & nfc::NCI_PKT_OID_MASK); + if (this->nci_message_.size() > NCI_PKT_OID_OFFSET) { + return oid == (this->nci_message_[NCI_PKT_OID_OFFSET] & NCI_PKT_OID_MASK); } return false; } bool NciMessage::simple_status_response_is(const uint8_t response) const { - if (this->nci_message_.size() > nfc::NCI_PKT_PAYLOAD_OFFSET) { - return response == this->nci_message_[nfc::NCI_PKT_PAYLOAD_OFFSET]; + if (this->nci_message_.size() > NCI_PKT_PAYLOAD_OFFSET) { + return response == this->nci_message_[NCI_PKT_PAYLOAD_OFFSET]; } return false; } void NciMessage::set_header(const uint8_t message_type, const uint8_t gid, const uint8_t oid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = - (message_type & nfc::NCI_PKT_MT_MASK) | (gid & nfc::NCI_PKT_GID_MASK); - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = (message_type & NCI_PKT_MT_MASK) | (gid & NCI_PKT_GID_MASK); + this->nci_message_[NCI_PKT_OID_OFFSET] = oid & NCI_PKT_OID_MASK; } -void NciMessage::set_message(const uint8_t message_type, const std::vector &payload) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); +void NciMessage::set_message(const uint8_t message_type, const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = message_type & NCI_PKT_MT_MASK; + this->nci_message_[NCI_PKT_OID_OFFSET] = 0; + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } void NciMessage::set_message(const uint8_t message_type, const uint8_t gid, const uint8_t oid, - const std::vector &payload) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = - (message_type & nfc::NCI_PKT_MT_MASK) | (gid & nfc::NCI_PKT_GID_MASK); - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); + const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->set_header(message_type, gid, oid); + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } void NciMessage::set_message_type(const uint8_t message_type) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - auto mt_masked = this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & ~nfc::NCI_PKT_MT_MASK; - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = mt_masked | (message_type & nfc::NCI_PKT_MT_MASK); + auto mt_masked = this->nci_message_[NCI_PKT_MT_GID_OFFSET] & ~NCI_PKT_MT_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = mt_masked | (message_type & NCI_PKT_MT_MASK); } void NciMessage::set_gid(const uint8_t gid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - auto gid_masked = this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & ~nfc::NCI_PKT_GID_MASK; - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = gid_masked | (gid & nfc::NCI_PKT_GID_MASK); + auto gid_masked = this->nci_message_[NCI_PKT_MT_GID_OFFSET] & ~NCI_PKT_GID_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = gid_masked | (gid & NCI_PKT_GID_MASK); } void NciMessage::set_oid(const uint8_t oid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; + this->nci_message_[NCI_PKT_OID_OFFSET] = oid & NCI_PKT_OID_MASK; } -void NciMessage::set_payload(const std::vector &payload) { - std::vector message(this->nci_message_.begin(), this->nci_message_.begin() + nfc::NCI_PKT_HEADER_SIZE); - - message.insert(message.end(), payload.begin(), payload.end()); - message[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_ = message; +void NciMessage::set_payload(const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } +void NciMessage::append(const std::span data) { + for (const uint8_t byte : data) { + this->nci_message_.push_back(byte); + } +} + +void NciMessage::set_payload_size(const uint8_t size) { this->nci_message_.resize(NCI_PKT_HEADER_SIZE + size); } + } // namespace esphome::nfc diff --git a/esphome/components/nfc/nci_message.h b/esphome/components/nfc/nci_message.h index 8e8b1103363..de8a4cf56ae 100644 --- a/esphome/components/nfc/nci_message.h +++ b/esphome/components/nfc/nci_message.h @@ -3,19 +3,30 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include +#include namespace esphome::nfc { +// An NCI packet is a three-byte header followed by up to 255 payload bytes +static constexpr size_t NCI_PKT_MAX_PAYLOAD_SIZE = 255; +static constexpr size_t NCI_PKT_MAX_SIZE = 3 + NCI_PKT_MAX_PAYLOAD_SIZE; + +/// One NCI packet, held in a fixed buffer so building and reading messages never allocates class NciMessage { public: - NciMessage() {} - NciMessage(uint8_t message_type, const std::vector &payload); - NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid); - NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, const std::vector &payload); - NciMessage(const std::vector &raw_packet); + using Buffer = StaticVector; - std::vector encode(); + NciMessage() { this->reset(); } + NciMessage(uint8_t message_type, std::span payload); + NciMessage(uint8_t message_type, std::initializer_list payload); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, std::span payload); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, std::initializer_list payload); + explicit NciMessage(std::span raw_packet); + + /// Stamps the payload length into the header and returns the packet ready to send + std::span encode(); void reset(); uint8_t get_message_type() const; @@ -24,7 +35,10 @@ class NciMessage { uint8_t get_payload_size(bool recompute = false); uint8_t get_simple_status_response() const; uint8_t get_message_byte(uint8_t offset) const; - std::vector &get_message(); + Buffer &get_message() { return this->nci_message_; } + const Buffer &get_message() const { return this->nci_message_; } + /// The payload bytes that follow the header + std::span get_payload() const; bool has_payload() const; bool message_type_is(uint8_t message_type) const; @@ -34,15 +48,25 @@ class NciMessage { bool simple_status_response_is(uint8_t response) const; void set_header(uint8_t message_type, uint8_t gid, uint8_t oid); - void set_message(uint8_t message_type, const std::vector &payload); - void set_message(uint8_t message_type, uint8_t gid, uint8_t oid, const std::vector &payload); + void set_message(uint8_t message_type, std::span payload); + void set_message(uint8_t message_type, uint8_t gid, uint8_t oid, std::span payload); void set_message_type(uint8_t message_type); void set_gid(uint8_t gid); void set_oid(uint8_t oid); - void set_payload(const std::vector &payload); + void set_payload(std::span payload); + void set_payload(std::initializer_list payload) { + this->set_payload(std::span(payload.begin(), payload.size())); + } + /// Appends bytes to the payload; bytes that do not fit are dropped + void append(std::span data); + void append(std::initializer_list data) { + this->append(std::span(data.begin(), data.size())); + } + /// Sets the packet size to the header plus `size` payload bytes, for a bus driver filling the buffer directly + void set_payload_size(uint8_t size); protected: - std::vector nci_message_{0, 0, 0}; // three bytes, MT/PBF/GID, OID, payload length/size + Buffer nci_message_; // MT/PBF/GID, OID, payload length, then the payload }; } // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index d33f3f7b5cb..e597b9192d4 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -5,7 +5,7 @@ namespace esphome::nfc { static const char *const TAG = "nfc.ndef_message"; -NdefMessage::NdefMessage(std::vector &data) { +NdefMessage::NdefMessage(const std::span data) { ESP_LOGV(TAG, "Building NdefMessage with %zu bytes", data.size()); size_t index = 0; while (index < data.size()) { @@ -97,7 +97,7 @@ bool NdefMessage::add_record(std::unique_ptr record) { ESP_LOGE(TAG, "Too many records. Max: %d", MAX_NDEF_RECORDS); return false; } - this->records_.emplace_back(std::move(record)); + this->records_.emplace_next() = std::move(record); return true; } diff --git a/esphome/components/nfc/ndef_message.h b/esphome/components/nfc/ndef_message.h index 7d431b22963..25ec7c00c7d 100644 --- a/esphome/components/nfc/ndef_message.h +++ b/esphome/components/nfc/ndef_message.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "esphome/core/helpers.h" @@ -12,19 +13,21 @@ namespace esphome::nfc { static constexpr uint8_t MAX_NDEF_RECORDS = 4; +/// The records of one message, owned in place so parsing a message allocates only the records themselves +using NdefRecords = StaticVector, MAX_NDEF_RECORDS>; class NdefMessage { public: NdefMessage() = default; - NdefMessage(std::vector &data); + NdefMessage(std::span data); + NdefMessage(std::vector &data) : NdefMessage(std::span(data)) {} NdefMessage(const NdefMessage &msg) { - records_.reserve(msg.records_.size()); for (const auto &r : msg.records_) { - records_.emplace_back(r->clone()); + records_.emplace_next() = r->clone(); } } - const std::vector> &get_records() { return this->records_; }; + const NdefRecords &get_records() const { return this->records_; }; bool add_record(std::unique_ptr record); bool add_text_record(const std::string &text); @@ -34,7 +37,7 @@ class NdefMessage { std::vector encode(); protected: - std::vector> records_; + NdefRecords records_; }; } // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 76a391f1dea..a9fc1c2b8c4 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -23,7 +23,7 @@ uint8_t guess_tag_type(uint8_t uid_length) { } } -int8_t get_mifare_classic_ndef_start_index(std::vector &data) { +int8_t get_mifare_classic_ndef_start_index(const std::span data) { for (uint8_t i = 0; i < MIFARE_CLASSIC_BLOCK_SIZE; i++) { if (data[i] == 0x00) { // Do nothing, skip @@ -36,7 +36,8 @@ int8_t get_mifare_classic_ndef_start_index(std::vector &data) { return -1; } -bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index) { +bool decode_mifare_classic_tlv(const std::span data, uint32_t &message_length, + uint8_t &message_start_index) { if (data.size() < MIFARE_CLASSIC_BLOCK_SIZE) { ESP_LOGE(TAG, "Error, data too short for NDEF detection."); return false; @@ -61,12 +62,32 @@ bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_len } uint32_t get_mifare_ultralight_buffer_size(uint32_t message_length) { - uint32_t buffer_size = message_length + 2 + 1; + // TLV header (2 bytes, or 4 for messages of 255 bytes or more) plus the terminator TLV + uint32_t buffer_size = message_length + (message_length < 255 ? 2 : 4) + 1; if (buffer_size % MIFARE_ULTRALIGHT_READ_SIZE != 0) buffer_size = ((buffer_size / MIFARE_ULTRALIGHT_READ_SIZE) + 1) * MIFARE_ULTRALIGHT_READ_SIZE; return buffer_size; } +void fill_ndef_tlv(const std::span message, const uint32_t buffer_length, FixedVector &buffer) { + buffer.init(buffer_length); + buffer.push_back(0x03); + if (message.size() < 255) { + buffer.push_back(message.size()); + } else { + buffer.push_back(0xFF); + buffer.push_back((message.size() >> 8) & 0xFF); + buffer.push_back(message.size() & 0xFF); + } + for (const uint8_t byte : message) { + buffer.push_back(byte); + } + buffer.push_back(0xFE); + while (buffer.size() < buffer_length) { + buffer.push_back(0x00); + } +} + uint32_t get_mifare_classic_buffer_size(uint32_t message_length) { uint32_t buffer_size = message_length; if (message_length < 255) { diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 36b27ce5f68..e0f73e934ca 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -64,9 +64,11 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192; char *format_bytes_to(char *buffer, std::span bytes); uint8_t guess_tag_type(uint8_t uid_length); -int8_t get_mifare_classic_ndef_start_index(std::vector &data); -bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index); +int8_t get_mifare_classic_ndef_start_index(std::span data); +bool decode_mifare_classic_tlv(std::span data, uint32_t &message_length, uint8_t &message_start_index); uint32_t get_mifare_classic_buffer_size(uint32_t message_length); +/// Fills `buffer` with the NDEF TLV (type, length, message, terminator) padded with zeros to `buffer_length` +void fill_ndef_tlv(std::span message, uint32_t buffer_length, FixedVector &buffer); bool mifare_classic_is_first_block(uint8_t block_num); bool mifare_classic_is_trailer_block(uint8_t block_num); @@ -81,10 +83,12 @@ class NfcTagListener { class Nfcc { public: +#ifdef NFC_TAG_LISTENER_COUNT void register_listener(NfcTagListener *listener) { this->tag_listeners_.push_back(listener); } protected: - std::vector tag_listeners_; + StaticVector tag_listeners_; +#endif }; } // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_helpers.cpp b/esphome/components/nfc/nfc_helpers.cpp index 6c8a5b626d5..82f4591bdf4 100644 --- a/esphome/components/nfc/nfc_helpers.cpp +++ b/esphome/components/nfc/nfc_helpers.cpp @@ -4,14 +4,14 @@ namespace esphome::nfc { static const char *const TAG = "nfc.helpers"; -bool has_ha_tag_ndef(NfcTag &tag) { return !get_ha_tag_ndef(tag).empty(); } +bool has_ha_tag_ndef(const NfcTag &tag) { return !get_ha_tag_ndef(tag).empty(); } -std::string get_ha_tag_ndef(NfcTag &tag) { +std::string get_ha_tag_ndef(const NfcTag &tag) { if (!tag.has_ndef_message()) { return std::string(); } - auto message = tag.get_ndef_message(); - auto records = message->get_records(); + const auto &message = tag.get_ndef_message(); + const auto &records = message->get_records(); for (const auto &record : records) { std::string payload = record->get_payload(); size_t pos = payload.find(HA_TAG_ID_PREFIX); diff --git a/esphome/components/nfc/nfc_helpers.h b/esphome/components/nfc/nfc_helpers.h index dedc602bf19..eff6db0ea2a 100644 --- a/esphome/components/nfc/nfc_helpers.h +++ b/esphome/components/nfc/nfc_helpers.h @@ -8,8 +8,8 @@ static const char HA_TAG_ID_EXT_RECORD_TYPE[] = "android.com:pkg"; static const char HA_TAG_ID_EXT_RECORD_PAYLOAD[] = "io.homeassistant.companion.android"; static const char HA_TAG_ID_PREFIX[] = "https://www.home-assistant.io/tag/"; -std::string get_ha_tag_ndef(NfcTag &tag); +std::string get_ha_tag_ndef(const NfcTag &tag); std::string get_random_ha_tag_ndef(); -bool has_ha_tag_ndef(NfcTag &tag); +bool has_ha_tag_ndef(const NfcTag &tag); } // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_tag.h b/esphome/components/nfc/nfc_tag.h index 6cc1a00c627..a20c435e21f 100644 --- a/esphome/components/nfc/nfc_tag.h +++ b/esphome/components/nfc/nfc_tag.h @@ -42,9 +42,10 @@ class NfcTag { } NfcTagUid &get_uid() { return this->uid_; }; - const std::string &get_tag_type() { return this->tag_type_; }; - bool has_ndef_message() { return this->ndef_message_ != nullptr; }; - const std::shared_ptr &get_ndef_message() { return this->ndef_message_; }; + const NfcTagUid &get_uid() const { return this->uid_; }; + const std::string &get_tag_type() const { return this->tag_type_; }; + bool has_ndef_message() const { return this->ndef_message_ != nullptr; }; + const std::shared_ptr &get_ndef_message() const { return this->ndef_message_; }; void set_ndef_message(std::unique_ptr ndef_message) { this->ndef_message_ = std::move(ndef_message); }; protected: diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 22b5868f1df..1491a44f085 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -10,13 +10,14 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "noise" noise_ns = cg.esphome_ns.namespace("noise") # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. -NOISE_C_VERSION = "0.1.21" -LIBSODIUM_VERSION = "1.10021.4" +NOISE_C_VERSION = "0.1.30" +LIBSODIUM_VERSION = "1.10021.11" CONFIG_SCHEMA = cv.Schema({}) @@ -75,11 +76,16 @@ def static_encryption_key(conf: ConfigType) -> str | 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)), - ) + so the key never occupies RAM. Components sharing one key (api and ota) + share the array.""" + decoded = decode_encryption_key(key) + arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) + if (array := arrays.get(decoded)) is None: + array = arrays[decoded] = cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decoded), + ) + return array def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 48067061673..86ea1ec8747 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -2,16 +2,13 @@ #ifdef USE_NOISE #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include #include #include -#ifdef USE_ESP8266 -#include -#endif - namespace esphome::noise { static const char *const TAG = "noise"; @@ -74,22 +71,13 @@ size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *rea return 0; } buf[0] = HANDSHAKE_STATUS_REJECT; -#ifdef USE_STORE_LOG_STR_IN_FLASH - // On ESP8266 with flash strings, we need to use PROGMEM-aware functions - size_t reason_len = strlen_P(reinterpret_cast(reason)); - reason_len = std::min(reason_len, capacity - 1); - if (reason_len > 0) { - memcpy_P(buf + 1, reinterpret_cast(reason), reason_len); - } -#else + // The reason may live in PROGMEM on ESP8266; the progmem helpers read RAM and flash alike const char *reason_str = LOG_STR_ARG(reason); - size_t reason_len = strlen(reason_str); - reason_len = std::min(reason_len, capacity - 1); + size_t reason_len = std::min(ESPHOME_strlen_P(reason_str), capacity - 1); if (reason_len > 0) { // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string - std::memcpy(buf + 1, reason_str, reason_len); + progmem_memcpy(buf + 1, reason_str, reason_len); } -#endif return reason_len + 1; } diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index cc7fa603c4e..64846dcb678 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -95,6 +95,7 @@ NoiseResponderHandshake::Action NoiseResponderHandshake::action() const { } int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { + CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); noise_buffer_set_input(mbuf, data, len); @@ -103,6 +104,7 @@ int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { out_len = 0; + CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); noise_buffer_set_output(mbuf, out, capacity); diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index aeeaba0c114..8e91cff0497 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,6 +8,7 @@ import shutil import subprocess from esphome import pins +from esphome.build_helpers import pch import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -74,6 +75,7 @@ from .framework import ( get_build_env, get_build_paths, setup_platformio_python_env, + toolchain_tool, ) # force import gpio to register pin schema @@ -175,6 +177,8 @@ def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() bootloaders: list[str] = [] + if CONF_BOARD not in config: + raise cv.Invalid("'board' is a required option for [nrf52].") board = config[CONF_BOARD] if board in BOARDS_ZEPHYR and KEY_BOOTLOADER in BOARDS_ZEPHYR[board]: @@ -249,7 +253,7 @@ CONFIG_SCHEMA = cv.All( ): cv.Schema( { cv.Optional(CONF_VERSION): cv.string_strict, - cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, + cv.Optional(CONF_LIBC_NANO): cv.boolean, cv.Optional( CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY ): cv.Schema( @@ -295,7 +299,7 @@ def _final_validate(config): conf = config[CONF_FRAMEWORK] advanced = conf[CONF_ADVANCED] - if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations: + if conf.get(CONF_LIBC_NANO, False) and "logger" in CORE.loaded_integrations: _LOGGER.warning( "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers " "such as %%zu are not supported and will print incorrectly. " @@ -401,7 +405,10 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA_ROLLBACK") zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) - zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO]) + zephyr_add_prj_conf( + "NEWLIB_LIBC_NANO", + conf.get(CONF_LIBC_NANO, "logger" not in CORE.loaded_integrations), + ) # c++ support if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("CPLUSPLUS", True) @@ -412,9 +419,6 @@ async def to_code(config: ConfigType) -> None: # watchdog zephyr_add_prj_conf("WATCHDOG", True) zephyr_add_prj_conf("WDT_DISABLE_AT_BOOT", False) - # disable console - zephyr_add_prj_conf("UART_CONSOLE", False) - zephyr_add_prj_conf("CONSOLE", False, False) # use NFC pins as GPIO if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True) @@ -428,6 +432,18 @@ async def to_code(config: ConfigType) -> None: ) zephyr_add_prj_conf("REBOOT", True) + # some boards enable USB and UART by default. + # disable it to prevent extra current consumption. + zephyr_add_prj_conf("USB_DEVICE_STACK", False, False) + zephyr_add_prj_conf("SERIAL", False, False) + + # disable stuff to make image smaller by default + zephyr_add_prj_conf("NCS_BOOT_BANNER", False, False) + zephyr_add_prj_conf("BOOT_BANNER", False, False) + zephyr_add_prj_conf("PRINTK", False, False) + zephyr_add_prj_conf("CONSOLE", False, False) + zephyr_add_prj_conf("UART_CONSOLE", False) + @coroutine_with_priority(CoroPriority.DIAGNOSTICS) async def _dfu_to_code(dfu_config): @@ -436,7 +452,12 @@ async def _dfu_to_code(dfu_config): if CONF_RESET_PIN in dfu_config: pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) cg.add(var.set_reset_pin(pin)) + + # DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power. + zephyr_add_prj_conf("USB_DEVICE_STACK", True) + zephyr_add_prj_conf("USB_CDC_ACM", True) zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) + zephyr_add_prj_conf("SERIAL", True) await cg.register_component(var, dfu_config) @@ -506,15 +527,15 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "download": f"{storage_json.name}.hex", }, ] - if (build_dir / APP_IMAGE_PATH).is_file(): - types += [ - { - "title": "App update package", - "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.", - "file": APP_IMAGE_PATH, - "download": f"app-{storage_json.name}.img", - }, - ] + if (build_dir / APP_IMAGE_PATH).is_file(): + types += [ + { + "title": "App update package", + "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.", + "file": APP_IMAGE_PATH, + "download": f"app-{storage_json.name}.img", + }, + ] return types @@ -762,7 +783,72 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False -def _generate_cmake_lists() -> bool: +# GCC only loads a precompiled header ahead of every other forced header, and +# Zephyr forces two with -imacros. They hold macros only, so the C++ sources +# of the app get them through the precompiled header. +_PCH_CMAKE_LINES = [ + "", + "# ESPHome precompiled header", + "get_property(esphome_options TARGET zephyr_interface", + " PROPERTY INTERFACE_COMPILE_OPTIONS)", + "set(esphome_kept_options)", + "set(esphome_pch_headers)", + "foreach(option IN LISTS esphome_options)", + ' if(option MATCHES "imacros> (.+)$")', + ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', + " list(APPEND esphome_kept_options", + ' "$<$,$,app>>>:${option}>")', + " else()", + ' list(APPEND esphome_kept_options "${option}")', + " endif()", + "endforeach()", + "if(NOT esphome_pch_headers)", + ' message(FATAL_ERROR "ESPHome: the headers Zephyr forces were not found, so "', + ' "the precompiled header would not load (set ESPHOME_PCH_ENABLE=0)")', + "endif()", + "set_property(TARGET zephyr_interface", + ' PROPERTY INTERFACE_COMPILE_OPTIONS "${esphome_kept_options}")', + *( + f'list(APPEND esphome_pch_headers "${{CMAKE_CURRENT_LIST_DIR}}/../src/{header}")' + for header in pch.PCH_DEFAULT_HEADERS + ), + 'list(TRANSFORM esphome_pch_headers REPLACE "(.+)" "$<$:\\\\1>")', + "target_precompile_headers(app PRIVATE ${esphome_pch_headers})", +] +# Where CMake puts the .gch of the app, below its binary dir +_PCH_SUM_PATH = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" + + +def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: + """Write the checksum ccache reads in place of the .gch. The app binary + dir only exists after the first configure; sysbuild nests it.""" + app_dir = build_dir / "zephyr" + if not (app_dir / "CMakeCache.txt").is_file(): + app_dir = build_dir + if not (app_dir / "CMakeCache.txt").is_file(): + return + checksum = pch.pch_checksum( + CORE.relative_src_path(), + pch.PCH_DEFAULT_HEADERS, + ( + str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), + zephyr_data()[KEY_BOARD], + # What the Zephyr configuration is generated from + *( + path.read_text(encoding="utf-8") + for path in sorted(source_dir.iterdir()) + if path.suffix in (".conf", ".overlay") + ), + ), + ) + write_file_if_changed(app_dir / _PCH_SUM_PATH, checksum + "\n") + + +def _pch_usable() -> bool: + return pch.pch_usable((toolchain_tool("g++"),)) + + +def _generate_cmake_lists(pch_on: bool) -> bool: """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() @@ -805,6 +891,9 @@ def _generate_cmake_lists() -> bool: ")", ] + if pch_on: + lines += _PCH_CMAKE_LINES + if link_flags: lines += [ "", @@ -824,6 +913,26 @@ def _copy_if_exists(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +def _west_build_command( + python_executable: Path, board: str, build_dir: Path, source_dir: Path +) -> list[str]: + return [ + str(python_executable), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + "--", + # Only adds -DNDEBUG (Kconfig sets the optimization level); picolibc used to force it + "-DCMAKE_BUILD_TYPE=MinSizeRel", + ] + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: # The actual build is done by PlatformIO (the caller falls through to @@ -841,7 +950,8 @@ def run_compile(args, config: ConfigType) -> bool: paths = get_build_paths() env = get_build_env() - cmake_lists_changed = _generate_cmake_lists() + pch_on = _pch_usable() + cmake_lists_changed = _generate_cmake_lists(pch_on) board = zephyr_data()[KEY_BOARD] build_dir = CORE.relative_pioenvs_path(CORE.name) @@ -858,18 +968,15 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) - west_cmd = [ - str(paths["python_executable"]), - "-m", - "west", - "build", - "--pristine=auto", - "-b", - board, - "-d", - str(build_dir), - str(source_dir), - ] + if pch_on: + pch.log_pch_in_use() + # Zephyr turns ccache on by itself when it is installed + env.update(pch.ccache_pch_env()) + _write_pch_checksum(build_dir, source_dir) + + west_cmd = _west_build_command( + paths["python_executable"], board, build_dir, source_dir + ) if not run_command_ok( west_cmd, diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index 2dd4b7bd09f..89272cd4066 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -20,9 +20,13 @@ commands. from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging from pathlib import Path +from .framework import bluetooth_west_projects + TIDY_PROJECT_NAME = "esphome_tidy" # Analyzed against the native toolchain's default SDK version @@ -33,19 +37,36 @@ _TIDY_BOARD = "adafruit_itsybitsy_nrf52840" # app target emits a C++ compile command to harvest flags/includes from. _TIDY_MAIN_CPP = "int main() { return 0; }\n" -# Kconfig superset enabling every subsystem an ESPHome nrf52 component may -# use, so the compile commands carry all of their include paths. -_TIDY_PRJ_CONF = """\ + +@dataclass(frozen=True) +class _TidySubsystem: + """A subsystem's Kconfig and the west projects its headers come from.""" + + name: str + prj_conf: str + # A callable when the projects differ by SDK version + west_projects: tuple[str, ...] | Callable[[], tuple[str, ...]] = () + + +# Kconfig superset for the compile commands and the projects it needs +_TIDY_SUBSYSTEMS = ( + _TidySubsystem( + "base", + """\ CONFIG_CPP=y CONFIG_STD_CPP20=y CONFIG_REQUIRES_FULL_LIBCPP=y CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y CONFIG_ADC=y # posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the # Zephyr POSIX headers clash with the libc ones under analysis CONFIG_POSIX_API=y -#mcumgr begin +""", + ), + _TidySubsystem("bluetooth", "CONFIG_BT=y\n", bluetooth_west_projects), + _TidySubsystem( + "mcumgr", + """\ CONFIG_NET_BUF=y CONFIG_ZCBOR=y CONFIG_MCUMGR=y @@ -60,14 +81,23 @@ CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin +""", + ("mcuboot", "zcbor"), + ), + _TidySubsystem( + "zigbee", + """\ CONFIG_ZIGBEE=y CONFIG_CRYPTO=y CONFIG_NVS=y CONFIG_SETTINGS=y -#zigbee end -""" +""", + ), +) + +_TIDY_PRJ_CONF = "".join( + f"# {subsystem.name}\n{subsystem.prj_conf}" for subsystem in _TIDY_SUBSYSTEMS +) def _tidy_cmakelists(library_include_dirs: str) -> str: @@ -166,6 +196,7 @@ def _setup_core(work_dir: Path) -> None: from esphome.core import CORE from . import RECOMMENDED_SDK_NRF_VERSION + from .framework import include_west_project CORE.name = TIDY_PROJECT_NAME # config_path's parent is the data-dir root for per-run artifacts. The @@ -178,6 +209,10 @@ def _setup_core(work_dir: Path) -> None: CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( RECOMMENDED_SDK_NRF_VERSION ) + for subsystem in _TIDY_SUBSYSTEMS: + projects = subsystem.west_projects + for project in projects() if callable(projects) else projects: + include_west_project(project) def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: @@ -237,6 +272,8 @@ def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: "zephyr_generated_headers", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + # As in a real build, so NDEBUG is set + "-DCMAKE_BUILD_TYPE=MinSizeRel", ] if not run_command_ok( west_cmd, diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 24dee997269..3d9219e96f8 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -14,6 +14,10 @@ static const char *const TAG = "dfu"; static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS static const uint8_t DFU_MAGIC_UF2_RESET = 0x57; // Adafruit nRF52 bootloader UF2 magic +static const uint8_t DFU_MAGIC_SKIP = 0x6d; // Adafruit nRF52 bootloader: start the app, skip DFU +// Host-side baud rates that trigger an action; see DeviceFirmwareUpdate::setup(). +static const uint32_t DFU_TOUCH_BAUD_RATE = 1200; // reboot into the bootloader (DFU) +static const uint32_t RESET_TOUCH_BAUD_RATE = 2001; // plain reboot back into the application void DeviceFirmwareUpdate::setup() { if (this->reset_pin_ != nullptr) { @@ -21,13 +25,20 @@ void DeviceFirmwareUpdate::setup() { } #if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) zephyr::global_cdc_acm->add_on_rate_callback([this](const device *, uint32_t rate) { - if (rate == 1200) { + if (rate == RESET_TOUCH_BAUD_RATE) { + // A plain reboot for host tools (a logs view's Reset device): the USB CDC has no + // reset line, and the bootloader, once entered, has no host command back to the app. + NRF_POWER->GPREGRET = DFU_MAGIC_SKIP; + arch_feed_wdt(); + App.reboot(); + } else if (rate == DFU_TOUCH_BAUD_RATE) { volatile uint32_t *dbl_reset_mem = (volatile uint32_t *) 0x20007F7C; (*dbl_reset_mem) = DFU_DBL_RESET_MAGIC; if (this->reset_pin_ != nullptr) { this->reset_pin_->digital_write(true); } else { NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + arch_feed_wdt(); App.reboot(); } } diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 5e2cf197fbb..48af47e8ffc 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,3 +1,7 @@ +from collections.abc import Iterator +import configparser +from contextlib import contextmanager +from dataclasses import dataclass, field import hashlib import logging import os @@ -7,6 +11,7 @@ import shutil import sys from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path +from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -80,6 +85,17 @@ def _get_toolchain_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "toolchains" / version +def toolchain_tool(name: str) -> Path: + """Path to one of the pinned Zephyr SDK's tools (objdump, readelf, ...). + + The single owner of the ``arm-zephyr-eabi/bin/arm-zephyr-eabi-`` + layout and the Windows suffix. + """ + suffix = ".exe" if os.name == "nt" else "" + bin_path = _get_toolchain_path(TOOLCHAIN_VERSION) / "arm-zephyr-eabi" / "bin" + return bin_path / f"arm-zephyr-eabi-{name}{suffix}" + + _SITECUSTOMIZE = """\ import os, stat, shutil _orig = shutil.rmtree @@ -247,8 +263,245 @@ def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: tmp.replace(uf2conv) +# West projects every build needs; components add others with include_west_project() +DEFAULT_WEST_PROJECTS = ("cmsis", "hal_nordic", "nrfxlib", "zephyr") + +_KEY_NRF52 = "nrf52" +# The projects a finished install fetched +_WEST_PROJECTS_FILE = ".west_projects" + + +@dataclass +class _Nrf52Data: + west_projects: set[str] = field(default_factory=lambda: set(DEFAULT_WEST_PROJECTS)) + + +def _get_data() -> _Nrf52Data: + if _KEY_NRF52 not in CORE.data: + CORE.data[_KEY_NRF52] = _Nrf52Data() + return CORE.data[_KEY_NRF52] + + +def include_west_project(name: str) -> None: + """Fetch a west project left out by default; call from to_code().""" + _get_data().west_projects.add(name) + + +def bluetooth_west_projects() -> tuple[str, ...]: + """Bluetooth's crypto: TinyCrypt up to SDK 3.1, PSA (mbedtls, Oberon) from 3.2.""" + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 2, 0): + return ("mbedtls", "oberon-psa-crypto") + return ("tinycrypt",) + + +def _wanted_west_projects() -> set[str]: + projects = set(_get_data().west_projects) + # Zephyr 4.1 moved the Cortex-M core headers to cmsis_6 + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 1, 0): + projects.add("cmsis_6") + # Sysbuild builds the MCUboot image with any bootloader + if CORE.data.get(KEY_ZEPHYR, {}).get(KEY_SYSBUILD): + projects.add("mcuboot") + return projects + + +def _set_project_filter( + env_python_path: Path, framework_path: Path, projects: set[str] +) -> bool: + # "--" keeps west from reading the leading "-" as an option + project_filter = ",".join(["-.*", *(f"+{p}" for p in sorted(projects))]) + cmd = [str(env_python_path), "-m", "west", "config", "manifest.project-filter"] + return run_command_ok([*cmd, "--", project_filter], cwd=framework_path) + + +def _check_west_projects( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Raise when the manifest lacks one of ``projects``; needs zephyr cloned. + + west update quietly skips an unknown name in the filter, west list fails on it. + """ + names = sorted(projects) + cmd = [str(env_python_path), "-m", "west", "list", "-f", "{name}", *names] + if not run_command_ok(cmd, cwd=framework_path): + raise EsphomeError( + f"west list failed for the requested nRF Connect SDK {version} projects " + f"({', '.join(names)}); a project the manifest does not have is the " + "usual cause, see west's output above" + ) + + +def _west_update( + env_python_path: Path, + framework_path: Path, + version: str, + projects: set[str], + checked: bool = False, +) -> bool: + """Fetch ``projects``; False when the fetch fails.""" + if not _set_project_filter(env_python_path, framework_path, projects): + return False + cmd = [ + str(env_python_path), + "-m", + "west", + "update", + "--narrow", + "--fetch-opt=--depth=1", + ] + # Streamed so the clone's progress reaches the log + if not run_command_ok(cmd, cwd=framework_path, stream_output=True): + return False + if not checked: + _check_west_projects(env_python_path, framework_path, version, projects) + (framework_path / _WEST_PROJECTS_FILE).write_text( + "\n".join(sorted(projects)), encoding="utf-8" + ) + return True + + +def _installed_west_projects(framework_path: Path) -> set[str] | None: + """The projects a finished install fetched; None when it has every project.""" + try: + stamp = (framework_path / _WEST_PROJECTS_FILE).read_text(encoding="utf-8") + except FileNotFoundError: + pass + else: + return set(stamp.split()) + # No stamp: an install from before the filter has every project, a filtered + # one that lost its stamp fetches again + config = configparser.ConfigParser() + if not config.read(framework_path / ".west" / "config", encoding="utf-8"): + return set() + if config.has_option("manifest", "project-filter"): + return set() + return None + + +def _restore_project_filter( + env_python_path: Path, framework_path: Path, version: str, installed: set[str] +) -> None: + """Put the filter back to the stamp's projects; the defaults always stay in.""" + projects = installed | set(DEFAULT_WEST_PROJECTS) + if not _set_project_filter(env_python_path, framework_path, projects): + _LOGGER.warning( + "Couldn't put the nRF Connect SDK %s project filter back; " + "the next build that fetches a project sets it again", + version, + ) + + +# Lock wait slices, so Ctrl-C stays responsive +_INSTALL_LOCK_POLL = 1 + + +@contextmanager +def _install_lock(name: str) -> Iterator[None]: + """Serialize a shared install step across builds running at once.""" + from filelock import FileLock, Timeout + + lock_path = get_sdk_nrf_tools_path() / f"{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + # No soft lock: its marker outlives a killed build and hangs every later one + lock = FileLock(str(lock_path), fallback_to_soft=False) + waiting = False + while True: + try: + lock.acquire(timeout=_INSTALL_LOCK_POLL) + break + except Timeout: # before OSError, which it subclasses + if not waiting: + waiting = True + _LOGGER.info("Waiting for another build installing %s ...", name) + except OSError as err: + _LOGGER.warning( + "Can't lock %s (%s), continuing without a lock", lock_path, err + ) + break + try: + yield + finally: + lock.release() + + +def _fetch_missing_west_projects( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Fetch the wanted projects a finished install lacks; it only ever gains.""" + if projects <= set(DEFAULT_WEST_PROJECTS): + return + installed = _installed_west_projects(framework_path) + # Before the fetch, so an unknown name costs nothing on any install + _check_west_projects(env_python_path, framework_path, version, projects) + if installed is None or not (missing := projects - installed): + return + _LOGGER.info( + "Fetching nRF Connect SDK %s projects: %s", version, ", ".join(sorted(missing)) + ) + wanted = installed | projects + if not _west_update(env_python_path, framework_path, version, wanted, checked=True): + _restore_project_filter(env_python_path, framework_path, version, installed) + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + + +def _install_framework( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Clone the nRF Connect SDK into ``framework_path`` with west. + + A download cut short after ``west init`` leaves the workspace behind; + rerunning ``west update`` there only fetches what is missing, so it resumes + instead of cloning about 2 GB again. A resume that fails keeps what was + fetched (a flaky network is the likely cause) and is retried on the next + build; only a second failure in a row starts over clean. + """ + resume_failed = framework_path / ".resume_failed" + # Resume only a workspace whose ``west init`` finished (it writes the + # config last). ``.ready`` with missing requirements is a damaged install, + # not an interrupted one, so it goes the clean way. + initialized = (framework_path / ".west" / "config").is_file() + if initialized and not (framework_path / ".ready").exists(): + _LOGGER.info("Resuming the nRF Connect SDK %s download ...", version) + if _west_update(env_python_path, framework_path, version, projects): + resume_failed.unlink(missing_ok=True) + return + if not resume_failed.exists(): + resume_failed.touch() + raise EsphomeError( + f"Can't resume the nRF Connect SDK {version} download; " + "the next build retries it" + ) + _LOGGER.warning( + "Resuming failed again; downloading nRF Connect SDK %s anew", version + ) + rmdir(framework_path, msg=f"Clean up {version} framework environment") + _LOGGER.info("Initializing nRF Connect SDK %s ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "init", + "-m", + "https://github.com/nrfconnect/sdk-nrf", + "-o=--depth=1", + "--mr", + version, + str(framework_path), + ] + if not run_command_ok(cmd, stream_output=True): + raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") + _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) + if not _west_update(env_python_path, framework_path, version, projects): + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + + def check_and_install() -> None: version = _get_version_str() + with _install_lock(f"sdk-{version}"): + _check_and_install(version) + + +def _check_and_install(version: str) -> None: python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" @@ -279,38 +532,15 @@ def check_and_install() -> None: framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + projects = _wanted_west_projects() if not sentinel.exists() or not zephyr_reqs.exists(): - rmdir(framework_path, msg=f"Clean up {version} framework environment") - _LOGGER.info("Initializing nRF Connect SDK %s ...", version) - cmd = [ - str(env_python_path), - "-m", - "west", - "init", - "-m", - "https://github.com/nrfconnect/sdk-nrf", - "-o=--depth=1", - "--mr", - version, - str(framework_path), - ] - if not run_command_ok(cmd): - raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") - _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) - cmd = [ - str(env_python_path), - "-m", - "west", - "update", - "--narrow", - "--fetch-opt=--depth=1", - ] - if not run_command_ok(cmd, cwd=framework_path): - raise EsphomeError(f"Can't update nRF Connect SDK {version}") + _install_framework(env_python_path, framework_path, version, projects) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() + else: + _fetch_missing_west_projects(env_python_path, framework_path, version, projects) zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" if ( @@ -331,6 +561,13 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() + # Shared by every SDK version; locked only while missing + if not (_get_toolchain_path(TOOLCHAIN_VERSION) / ".ready").exists(): + with _install_lock(f"toolchain-{TOOLCHAIN_VERSION}"): + _install_toolchain() + + +def _install_toolchain() -> None: toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): diff --git a/esphome/components/nrf52/toolchain.py b/esphome/components/nrf52/toolchain.py new file mode 100644 index 00000000000..aaa317aa5f5 --- /dev/null +++ b/esphome/components/nrf52/toolchain.py @@ -0,0 +1,28 @@ +"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis.""" + +from pathlib import Path + +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + +from .framework import toolchain_tool + + +def get_objdump_path() -> Path: + return toolchain_tool("objdump") + + +def get_readelf_path() -> Path: + return toolchain_tool("readelf") + + +def get_elf_path() -> Path: + """The linked Zephyr image, at the layout the configured SDK version writes. + + Chosen by version rather than by probing so a leftover image from another + SDK layout is never analyzed in place of the current build. + """ + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2): + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf") + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf") diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f66..8edbdecee5f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,16 +79,18 @@ from esphome.const import ( DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ @@ -163,10 +165,6 @@ ValueRangeTrigger = number_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) -# Actions -NumberSetAction = number_ns.class_("NumberSetAction", automation.Action) -NumberOperationAction = number_ns.class_("NumberOperationAction", automation.Action) - # Conditions NumberInRangeCondition = number_ns.class_( "NumberInRangeCondition", automation.Condition @@ -174,6 +172,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +218,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +288,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) @@ -325,6 +327,13 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl return var +def sub_numbers( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_number.""" + return SubEntities(new_number, config, parent) + + NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Number), @@ -362,85 +371,40 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "number.set", - NumberSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), + call="make_call", ) -async def number_set_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_value(template_)) - return var +# The operation is fixed by the action name; CONF_MODE only stays accepted in the config. +for _name, _mode, _cycle in ( + ("number.increment", "INCREMENT", True), + ("number.decrement", "DECREMENT", True), + ("number.to_min", "TO_MIN", False), + ("number.to_max", "TO_MAX", False), +): + _schema = {cv.Optional(CONF_MODE, default=_mode): cv.one_of(_mode, upper=True)} + _fields = [ + automation.ApplyCall(f"with_operation({NUMBER_OPERATION_OPTIONS[_mode]})") + ] + if _cycle: + _schema[cv.Optional(CONF_CYCLE, default=True)] = cv.boolean + _fields.append(automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_)) + automation.register_apply_action( + _name, + automation.maybe_simple_id(OPERATION_BASE_SCHEMA.extend(_schema)), + *_fields, + call="make_call", + ) -@automation.register_action( - "number.increment", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="INCREMENT"): cv.one_of( - "INCREMENT", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.decrement", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="DECREMENT"): cv.one_of( - "DECREMENT", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.to_min", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="TO_MIN"): cv.one_of( - "TO_MIN", upper=True - ), - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.to_max", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="TO_MAX"): cv.one_of( - "TO_MAX", upper=True - ), - } - ) - ), - synchronous=True, -) -@automation.register_action( +automation.register_apply_action( "number.operation", - NumberOperationAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPERATION): cv.templatable( @@ -449,23 +413,7 @@ async def number_set_to_code(config, action_id, template_arg, args): cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_OPERATION, "with_operation", NumberOperation), + automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_), + call="make_call", ) -async def number_to_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (operation := config.get(CONF_OPERATION)) is not None: - to_ = await cg.templatable(operation, args, NumberOperation) - cg.add(var.set_operation(to_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable( - NUMBER_OPERATION_OPTIONS[mode], args, NumberOperation - ) - cg.add(var.set_operation(template_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - return var diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 4efcfd30d8e..b3d9dfaffbe 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,47 +6,6 @@ namespace esphome::number { -class NumberStateTrigger final : public Trigger { - public: - explicit NumberStateTrigger(Number *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - -template class NumberSetAction final : public Action { - public: - NumberSetAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Number *number_; -}; - -template class NumberOperationAction final : public Action { - public: - explicit NumberOperationAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(NumberOperation, operation) - TEMPLATABLE_VALUE(bool, cycle) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Number *number_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 579d488cf06..b697e770be0 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -28,7 +28,7 @@ class Number; */ class Number : public EntityBase { public: - float state; + float state{}; void publish_state(float state); diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9bf..3c7942b9a36 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index 3e9517937ed..a36f3a94947 100644 --- a/esphome/components/online_image/image.py +++ b/esphome/components/online_image/image.py @@ -14,8 +14,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import ID, Lambda -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import Lambda from esphome.types import ConfigType AUTO_LOAD = ["runtime_image"] @@ -31,14 +30,6 @@ OnlineImage = online_image_ns.class_( "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) - ONLINE_IMAGE_SCHEMA = ( runtime_image.runtime_image_schema(OnlineImage) .extend( @@ -90,31 +81,18 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +automation.register_apply_action( + "online_image.set_url", + SET_URL_SCHEMA, + automation.ApplyField(CONF_URL, "set_url", cg.std_string), + automation.ApplyField(CONF_UPDATE, "update_if", cg.bool_), ) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var +automation.register_apply_action( + "online_image.release", + RELEASE_IMAGE_SCHEMA, + automation.ApplyCall("release()"), +) _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 3f2382accd1..a21849d618c 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -122,11 +122,11 @@ void OnlineImage::update() { return; } } - ESP_LOGD(TAG, "Using image format: %d", format); + ESP_LOGD(TAG, "Using image format: %s", LOG_STR_ARG(runtime_image::get_format_name(format))); // Initialize decoder with the known format if (!this->begin_decode(total_size, format)) { - ESP_LOGE(TAG, "Failed to initialize decoder for format %d", format); + ESP_LOGE(TAG, "Failed to initialize decoder for format %s", LOG_STR_ARG(runtime_image::get_format_name(format))); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 3e386f8cc8e..8b3485b8f74 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -43,6 +43,11 @@ class OnlineImage final : public PollingComponent, bool is_big_endian = false); void update() override; + /** Download now when `update` is true (the `update` flag of `online_image.set_url`). */ + void update_if(bool update) { + if (update) + this->update(); + } void loop() override; /** Set the URL to download the image from. */ @@ -104,29 +109,4 @@ class OnlineImage final : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction final : public Action { - public: - OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, url) - TEMPLATABLE_VALUE(bool, update) - void play(const Ts &...x) override { - this->parent_->set_url(this->url_.value(x...)); - if (this->update_.value(x...)) { - this->parent_->update(); - } - } - - protected: - OnlineImage *parent_; -}; - -template class OnlineImageReleaseAction final : public Action { - public: - OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->release(); } - - protected: - OnlineImage *parent_; -}; - } // namespace esphome::online_image diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index ab69f5d9ae5..ac14efc8e68 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,10 +13,13 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_tls_extras, + require_mbedtls_tls_server, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage from esphome.components.network import add_use_address +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -28,17 +31,17 @@ from esphome.const import ( CONF_LOG_LEVEL, CONF_OUTPUT_POWER, CONF_USE_ADDRESS, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, PlatformFramework, ) from esphome.core import ( CORE, - ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -109,6 +112,14 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + # OpenThread's DTLS commissioner is a TLS server, and its crypto platform + # uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component + # from trimming them out of mbedTLS. + require_mbedtls_tls_server() + require_mbedtls_tls_extras( + ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") + ) + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -309,6 +320,11 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) elif CORE.using_zephyr: + # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 + include_west_project("mbedtls") + include_west_project("openthread") + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 7, 0): + include_west_project("oberon-psa-crypto") zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) zephyr_add_prj_conf( f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True @@ -318,12 +334,6 @@ async def to_code(config: ConfigType) -> None: # Actions -OpenThreadComponentPollPeriodAction = openthread_ns.class_( - "OpenThreadComponentPollPeriodAction", - automation.Action, - cg.Parented.template(OpenThreadComponent), -) - POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( CONF_POLL_PERIOD, cv.Schema( @@ -337,20 +347,8 @@ POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( ) -@automation.register_action( +automation.register_apply_action( "openthread.set_poll_period", - OpenThreadComponentPollPeriodAction, POLL_PERIOD_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_POLL_PERIOD, "apply_poll_period", cg.uint32), ) -async def openthread_poll_period_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) - cg.add(var.set_poll_period(template_)) - return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp deleted file mode 100644 index 770bf124c55..00000000000 --- a/esphome/components/openthread/automation.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "esphome/core/defines.h" - -#ifdef USE_OPENTHREAD - -#include "automation.h" -#include "esphome/core/log.h" - -namespace esphome::openthread { - -static const char *const TAG = "openthread.automation"; - -void OpenThreadComponentBaseAction::warn_ftd_no_op_() { - ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); -} - -void OpenThreadComponentBaseAction::lock_and_apply_() { - if (this->parent_->is_ready()) { - if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { - if (auto *instance = lock.get_instance(); instance != nullptr) { - this->apply_locked(instance); - } - } else { - ESP_LOGW(TAG, "Failed to acquire lock in action"); - } - } else { - // Action may trigger early before setup, e.g. due to enabled "restore mode". - // Trying to acquire lock would fail! - // - // But default component values already have been overwritten. - // It is sufficient to let component apply those later during setup. - ESP_LOGD(TAG, "Not (yet) ready to apply"); - } -} - -} // namespace esphome::openthread - -#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h deleted file mode 100644 index 3706499fda0..00000000000 --- a/esphome/components/openthread/automation.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_OPENTHREAD -#include "openthread.h" - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -namespace esphome::openthread { - -/** Base class allowing to fetch OpenThread lock from parent component - * while applying action - * - * - Nontemplate aspects belong here to avoid template bloat. - * - Subclasses implement virtual action method that is called under lock. - * - Seal leaf subclasses via @a final to support devirtualization. - */ -class OpenThreadComponentBaseAction : public Parented { - public: - // Enforce ctor with parent argument (not without args) - explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} - - protected: - /** Handler to implement in subclass for applying action parts that need lock */ - virtual void apply_locked(otInstance *instance) = 0; - - /** Fetch OT lock and then call @a apply_locked */ - void lock_and_apply_(); - - /** Log a warning that this action has no effect on FTD devices */ - void warn_ftd_no_op_(); - - /** Timeout (ms) for acquiring OT lock */ - static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; -}; - -/** Action to set single poll period parameter */ -template -class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { - TEMPLATABLE_VALUE(uint32_t, poll_period) - - public: - /* Passthrough ctor */ - using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; - - protected: - void play(const Ts &...x) override { -#if CONFIG_OPENTHREAD_MTD - this->parent_->set_poll_period(this->poll_period_.value(x...)); - - this->lock_and_apply_(); -#else - this->warn_ftd_no_op_(); -#endif - } - - void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } -}; - -} // namespace esphome::openthread -#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index b98f1091724..c788b1f968d 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,8 +227,6 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } -void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } - bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { @@ -284,6 +282,25 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { +#if CONFIG_OPENTHREAD_MTD + this->set_poll_period(poll_period); + if (!this->is_lock_initialized()) { + // The action may run before the stack is up, e.g. from a restore mode; ot_main applies the stored value. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + return; + } + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + return; + } + this->apply_linkmode_(lock.get_instance()); +#else + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +#endif +} + void OpenThreadComponent::apply_linkmode_(otInstance *instance) { otLinkModeConfig link_mode_config{}; #if CONFIG_OPENTHREAD_FTD diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f4c6d0962ae..8bccae7f85e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -25,8 +25,6 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; -template class OpenThreadComponentPollPeriodAction; - class OpenThreadComponent final : public Component { public: OpenThreadComponent(); @@ -53,15 +51,13 @@ class OpenThreadComponent final : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } uint32_t get_poll_period() const { return this->poll_period_; } #endif + /// Set the poll period and re-apply the link mode under the OT lock; a warning only on FTD builds. + void apply_poll_period(uint32_t poll_period); void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); protected: - // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() - // without exposing this lock-sensitive, raw-instance method on the public API. - template friend class OpenThreadComponentPollPeriodAction; - /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. * ot_main() runs on the OpenThread task itself and must not acquire the lock. @@ -90,7 +86,7 @@ extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguide class OpenThreadSrpComponent final : public Component { public: - void set_mdns(esphome::mdns::MDNSComponent *mdns); + void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index a2e6953a162..6031a684894 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_ON_STATE_CHANGE from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -38,7 +39,6 @@ CONF_ON_ABORT = "on_abort" CONF_ON_BEGIN = "on_begin" CONF_ON_END = "on_end" CONF_ON_PROGRESS = "on_progress" -CONF_ON_STATE_CHANGE = "on_state_change" ota_ns = cg.esphome_ns.namespace("ota") diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 501d6ac241d..2192a794410 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens // a thin margin. One short-lived allocation right before reboot is not the - // fragmentation pattern the project guards against. nothrow so an OOM here - // fails closed like every other error path, rather than aborting. - std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + // fragmentation pattern the project guards against. An OOM returns nullptr + // and fails closed like every other error path. Internal RAM first: the + // block is an esp_partition_read target. + auto block = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE); if (!block) { OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); return false; diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5e..1b794037848 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,12 +14,26 @@ from esphome.const import ( CONF_POWER_SUPPLY, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "output" CONF_ZERO_MEANS_ZERO = "zero_means_zero" + +@dataclass +class OutputData: + power_scaling: bool = False + + +def _get_data() -> OutputData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = OutputData() + return CORE.data[DOMAIN] + + BINARY_OUTPUT_SCHEMA = cv.Schema( { cv.Optional(CONF_POWER_SUPPLY): cv.use_id(power_supply.PowerSupply), @@ -39,13 +55,6 @@ BinaryOutputPtr = BinaryOutput.operator("ptr") FloatOutput = output_ns.class_("FloatOutput", BinaryOutput) FloatOutputPtr = FloatOutput.operator("ptr") -# Actions -TurnOffAction = output_ns.class_("TurnOffAction", automation.Action) -TurnOnAction = output_ns.class_("TurnOnAction", automation.Action) -SetLevelAction = output_ns.class_("SetLevelAction", automation.Action) -SetMinPowerAction = output_ns.class_("SetMinPowerAction", automation.Action) -SetMaxPowerAction = output_ns.class_("SetMaxPowerAction", automation.Action) - async def setup_output_platform_(obj, config): if CONF_INVERTED in config: @@ -53,12 +62,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. @@ -80,81 +94,49 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_on_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +def _enable_power_scaling(config: ConfigType) -> ConfigType: + # set_min_power/set_max_power only exist with the define; to_code emits it from this fact. + _get_data().power_scaling = True + return config -@automation.register_action( - "output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_off_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("output.turn_on", "turn_on()"), + ("output.turn_off", "turn_off()"), +): + automation.register_apply_action( + _name, BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( +automation.register_apply_action( "output.set_level", - SetLevelAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(FloatOutput), cv.Required(CONF_LEVEL): cv.templatable(cv.percentage), } ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "set_level", cg.float_), ) -async def output_set_level_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) - cg.add(var.set_level(template_)) - return var - -@automation.register_action( - "output.set_min_power", - SetMinPowerAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(FloatOutput), - cv.Required(CONF_MIN_POWER): cv.templatable(cv.percentage), - } - ), - synchronous=True, -) -async def output_set_min_power_to_code(config, action_id, template_arg, args): - cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) - cg.add(var.set_min_power(template_)) - return var - - -@automation.register_action( - "output.set_max_power", - SetMaxPowerAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(FloatOutput), - cv.Required(CONF_MAX_POWER): cv.templatable(cv.percentage), - } - ), - synchronous=True, -) -async def output_set_max_power_to_code(config, action_id, template_arg, args): - cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) - cg.add(var.set_max_power(template_)) - return var +for _name, _key, _target in ( + ("output.set_min_power", CONF_MIN_POWER, "set_min_power"), + ("output.set_max_power", CONF_MAX_POWER, "set_max_power"), +): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(FloatOutput), + cv.Required(_key): cv.templatable(cv.percentage), + } + ).add_extra(_enable_power_scaling), + automation.ApplyField(_key, _target, cg.float_), + ) async def to_code(config): cg.add_define("USE_OUTPUT") + if _get_data().power_scaling: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add_global(output_ns.using) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h deleted file mode 100644 index efe775ba57d..00000000000 --- a/esphome/components/output/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/core/defines.h" -#include "esphome/components/output/binary_output.h" -#include "esphome/components/output/float_output.h" - -namespace esphome::output { - -template class TurnOffAction final : public Action { - public: - TurnOffAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_off(); } - - protected: - BinaryOutput *output_; -}; - -template class TurnOnAction final : public Action { - public: - TurnOnAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_on(); } - - protected: - BinaryOutput *output_; -}; - -template class SetLevelAction final : public Action { - public: - SetLevelAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_level(this->level_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -#ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction final : public Action { - public: - SetMinPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, min_power) - - void play(const Ts &...x) override { this->output_->set_min_power(this->min_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -template class SetMaxPowerAction final : public Action { - public: - SetMaxPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, max_power) - - void play(const Ts &...x) override { this->output_->set_max_power(this->max_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; -#endif // USE_OUTPUT_FLOAT_POWER_SCALING - -} // namespace esphome::output diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f4235728..57c8c553f65 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 7cee2a86398..a21cbc0f6b9 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -6,21 +6,9 @@ namespace esphome::output { static const char *const TAG = "output.switch"; void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } -void OutputSwitch::setup() { - bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } -} +void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { - if (state) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(state); this->publish_state(state); } diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 171c6753aec..703af8281d2 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -128,7 +128,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All( cv.Required(CONF_URL): cv.url, cv.Optional(CONF_PATH): cv.string, cv.Optional(CONF_USERNAME): cv.string, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.string), cv.Exclusive(CONF_FILE, CONF_FILES): validate_yaml_filename, cv.Exclusive(CONF_FILES, CONF_FILES): cv.All( cv.ensure_list( diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 05e945d1763..cc95f147ac5 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -55,6 +55,9 @@ class PCA9554Component final : public Component, /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. class PCA9554GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. + PCA9554GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -69,10 +72,10 @@ class PCA9554GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCA9554Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCA9554Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pca9554 diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 659260ba5e5..717803490a5 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -81,13 +81,4 @@ class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevic } pcf85063_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::pcf85063 diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 771461905e0..3f993c17e19 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@brogon"] @@ -13,8 +11,6 @@ pcf85063_ns = cg.esphome_ns.namespace("pcf85063") PCF85063Component = pcf85063_ns.class_( "PCF85063Component", time.RealTimeClock, i2c.I2CDevice ) -WriteAction = pcf85063_ns.class_("WriteAction", automation.Action) -ReadAction = pcf85063_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -24,46 +20,25 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -@automation.register_action( +automation.register_apply_action( "pcf85063.write_time", - WriteAction, cv.Schema( { cv.GenerateID(): cv.use_id(PCF85063Component), } ), - synchronous=True, + automation.ApplyCall("write_time()"), ) -async def pcf85063_write_time_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]) - return var - -@automation.register_action( +automation.register_apply_action( "pcf85063.read_time", - ReadAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(PCF85063Component), } ), - synchronous=True, + automation.ApplyCall("read_time()"), ) -async def pcf85063_read_time_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]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index e208774c2c6..2921d4f0475 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -109,13 +109,4 @@ class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice } pcf8563_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::pcf8563 diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 8a0b871be9b..3f677280c5b 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -16,8 +14,6 @@ pcf8563_ns = cg.esphome_ns.namespace("pcf8563") pcf8563Component = pcf8563_ns.class_( "PCF8563Component", time.RealTimeClock, i2c.I2CDevice ) -WriteAction = pcf8563_ns.class_("WriteAction", automation.Action) -ReadAction = pcf8563_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -27,46 +23,18 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -@automation.register_action( - "pcf8563.write_time", - WriteAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(pcf8563Component), - } - ), - synchronous=True, +PCF8563_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(pcf8563Component), + } ) -async def pcf8563_write_time_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]) - return var - -@automation.register_action( - "pcf8563.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(pcf8563Component), - } - ), - synchronous=True, +automation.register_apply_action( + "pcf8563.write_time", PCF8563_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) +automation.register_apply_action( + "pcf8563.read_time", PCF8563_ACTION_SCHEMA, automation.ApplyCall("read_time()") ) -async def pcf8563_read_time_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]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index e8f78bae506..9879d6a47eb 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -51,6 +51,9 @@ class PCF8574Component final : public Component, /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. class PCF8574GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. + PCF8574GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -65,10 +68,10 @@ class PCF8574GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCF8574Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCF8574Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pcf8574 diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 4945547f2eb..1527fa6f354 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,19 +3,11 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import Lambda from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) -PIDAutotuneAction = pid_ns.class_("PIDAutotuneAction", automation.Action) -PIDResetIntegralTermAction = pid_ns.class_( - "PIDResetIntegralTermAction", automation.Action -) -PIDSetControlParametersAction = pid_ns.class_( - "PIDSetControlParametersAction", automation.Action -) CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature" @@ -43,6 +35,24 @@ CONF_KP_MULTIPLIER = "kp_multiplier" CONF_KI_MULTIPLIER = "ki_multiplier" CONF_KD_MULTIPLIER = "kd_multiplier" + +def _validate_thresholds(config: ConfigType) -> ConfigType: + # Same rule as PIDClimate::set_deadband_thresholds; equal is allowed since 0/0 is the default. + if config[CONF_THRESHOLD_LOW] > config[CONF_THRESHOLD_HIGH]: + raise cv.Invalid( + f"{CONF_THRESHOLD_LOW} must not be greater than {CONF_THRESHOLD_HIGH}" + ) + return config + + +def _validate_threshold_action(config: ConfigType) -> ConfigType: + threshold_low = config[CONF_THRESHOLD_LOW] + threshold_high = config[CONF_THRESHOLD_HIGH] + if isinstance(threshold_low, Lambda) or isinstance(threshold_high, Lambda): + return config + return _validate_thresholds(config) + + CONFIG_SCHEMA = cv.All( climate.climate_schema(PIDClimate).extend( { @@ -51,7 +61,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_DEFAULT_TARGET_TEMPERATURE): cv.temperature, cv.Optional(CONF_COOL_OUTPUT): cv.use_id(output.FloatOutput), cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput), - cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema( + cv.Optional(CONF_DEADBAND_PARAMETERS): cv.All( { cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta, cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta, @@ -61,7 +71,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1 ): cv.positive_not_null_int, - } + }, + _validate_thresholds, ), cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema( { @@ -134,29 +145,19 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE])) -@automation.register_action( +automation.register_apply_action( "climate.pid.reset_integral_term", - PIDResetIntegralTermAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), } ), - synchronous=True, + automation.ApplyCall("reset_integral_term()"), ) -async def pid_reset_integral_term( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_apply_action( "climate.pid.autotune", - PIDAutotuneAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -169,25 +170,19 @@ async def pid_reset_integral_term( ): cv.possibly_negative_percentage, } ), - synchronous=True, + automation.ApplyCall( + "start_autotune({}, {}, {})", + ( + (CONF_NOISEBAND, cg.float_), + (CONF_POSITIVE_OUTPUT, cg.float_), + (CONF_NEGATIVE_OUTPUT, cg.float_), + ), + ), ) -async def esp8266_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - cg.add(var.set_noiseband(config[CONF_NOISEBAND])) - cg.add(var.set_positive_output(config[CONF_POSITIVE_OUTPUT])) - cg.add(var.set_negative_output(config[CONF_NEGATIVE_OUTPUT])) - return var -@automation.register_action( +automation.register_apply_action( "climate.pid.set_control_parameters", - PIDSetControlParametersAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -196,24 +191,44 @@ async def esp8266_set_frequency_to_code( cv.Optional(CONF_KD, default=0.0): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + automation.ApplyField(CONF_KD, "set_kd", cg.float_), ) -async def set_control_parameters( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - kp_template_ = await cg.templatable(config[CONF_KP], args, cg.float_) - cg.add(var.set_kp(kp_template_)) - ki_template_ = await cg.templatable(config[CONF_KI], args, cg.float_) - cg.add(var.set_ki(ki_template_)) +automation.register_apply_action( + "climate.pid.set_deadband_control_parameters_multipliers", + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PIDClimate), + # kp_multiplier is required for compatibility with the original action API; + # ki_multiplier and kd_multiplier are optional overrides. + cv.Required(CONF_KP_MULTIPLIER): cv.templatable(cv.float_), + cv.Optional(CONF_KI_MULTIPLIER): cv.templatable(cv.float_), + cv.Optional(CONF_KD_MULTIPLIER): cv.templatable(cv.float_), + } + ), + automation.ApplyField(CONF_KP_MULTIPLIER, "set_kp_multiplier", cg.float_), + automation.ApplyField(CONF_KI_MULTIPLIER, "set_ki_multiplier", cg.float_), + automation.ApplyField(CONF_KD_MULTIPLIER, "set_kd_multiplier", cg.float_), +) - kd_template_ = await cg.templatable(config[CONF_KD], args, cg.float_) - cg.add(var.set_kd(kd_template_)) - return var +automation.register_apply_action( + "climate.pid.set_deadband_threshold_parameters", + automation.maybe_simple_id( + cv.All( + { + cv.Required(CONF_ID): cv.use_id(PIDClimate), + cv.Required(CONF_THRESHOLD_HIGH): cv.templatable(cv.temperature_delta), + cv.Required(CONF_THRESHOLD_LOW): cv.templatable(cv.temperature_delta), + }, + _validate_threshold_action, + ) + ), + automation.ApplyCall( + "set_deadband_thresholds({}, {})", + ((CONF_THRESHOLD_LOW, cg.float_), (CONF_THRESHOLD_HIGH, cg.float_)), + ), +) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 8c9231fda61..1f0d45b0847 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -5,6 +5,17 @@ namespace esphome::pid { static const char *const TAG = "pid.climate"; +bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { + if (threshold_low > threshold_high) { + ESP_LOGW(TAG, "Deadband threshold low %.2f must not be greater than high %.2f", threshold_low, threshold_high); + return false; + } + + this->set_threshold_low(threshold_low); + this->set_threshold_high(threshold_high); + return true; +} + void PIDClimate::setup() { this->sensor_->add_on_state_callback([this](float state) { // only publish if state/current temperature has changed in two digits of precision @@ -158,6 +169,14 @@ void PIDClimate::update_pid_() { if (this->do_publish_) this->publish_state(); } +void PIDClimate::start_autotune(float noiseband, float positive_output, float negative_output) { + auto tuner = make_unique(); + tuner->set_noiseband(noiseband); + tuner->set_output_positive(positive_output); + tuner->set_output_negative(negative_output); + this->start_autotune(std::move(tuner)); +} + void PIDClimate::start_autotune(std::unique_ptr &&autotune) { this->autotuner_ = std::move(autotune); float min_value = this->supports_cool_() ? -1.0f : 0.0f; diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 7269709ab9b..6fea2bedf30 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -26,6 +26,7 @@ class PIDClimate final : public climate::Climate, public Component { void set_kd(float kd) { controller_.kd_ = kd; } void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; } void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; } + bool set_deadband_thresholds(float threshold_low, float threshold_high); void set_output_samples(int in) { controller_.output_samples_ = in; } void set_derivative_samples(int in) { controller_.derivative_samples_ = in; @@ -78,6 +79,8 @@ class PIDClimate final : public climate::Climate, public Component { default_target_temperature_ = default_target_temperature; } void start_autotune(std::unique_ptr &&autotune); + /// Build a tuner from the climate.pid.autotune settings and start it. + void start_autotune(float noiseband, float positive_output, float negative_output); void reset_integral_term(); protected: @@ -108,59 +111,4 @@ class PIDClimate final : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction final : public Action { - public: - PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} - - void set_noiseband(float noiseband) { noiseband_ = noiseband; } - void set_positive_output(float positive_output) { positive_output_ = positive_output; } - void set_negative_output(float negative_output) { negative_output_ = negative_output; } - - void play(const Ts &...x) { - auto tuner = make_unique(); - tuner->set_noiseband(this->noiseband_); - tuner->set_output_negative(this->negative_output_); - tuner->set_output_positive(this->positive_output_); - this->parent_->start_autotune(std::move(tuner)); - } - - protected: - float noiseband_; - float positive_output_; - float negative_output_; - PIDClimate *parent_; -}; - -template class PIDResetIntegralTermAction final : public Action { - public: - PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->reset_integral_term(); } - - protected: - PIDClimate *parent_; -}; - -template class PIDSetControlParametersAction final : public Action { - public: - PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} - - void play(const Ts &...x) { - auto kp = this->kp_.value(x...); - auto ki = this->ki_.value(x...); - auto kd = this->kd_.value(x...); - - this->parent_->set_kp(kp); - this->parent_->set_ki(ki); - this->parent_->set_kd(kd); - } - - protected: - TEMPLATABLE_VALUE(float, kp) - TEMPLATABLE_VALUE(float, ki) - TEMPLATABLE_VALUE(float, kd) - - PIDClimate *parent_; -}; - } // namespace esphome::pid diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index 94d641de47b..242a2440f87 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -2,7 +2,15 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.const import ( + CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, + ICON_GAUGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PERCENT, +) from esphome.types import ConfigType from ..climate import PIDClimate, pid_ns @@ -11,22 +19,55 @@ PIDClimateSensor = pid_ns.class_("PIDClimateSensor", sensor.Sensor, cg.Component PIDClimateSensorType = pid_ns.enum("PIDClimateSensorType") PID_CLIMATE_SENSOR_TYPES = { - "RESULT": PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, - "ERROR": PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, - "PROPORTIONAL": PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, - "INTEGRAL": PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, - "DERIVATIVE": PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, - "HEAT": PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, - "COOL": PIDClimateSensorType.PID_SENSOR_TYPE_COOL, - "KP": PIDClimateSensorType.PID_SENSOR_TYPE_KP, - "KI": PIDClimateSensorType.PID_SENSOR_TYPE_KI, - "KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD, + "RESULT": (PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, UNIT_PERCENT), + "ERROR": (PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, UNIT_PERCENT), + "PROPORTIONAL": (PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, UNIT_PERCENT), + "INTEGRAL": (PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, UNIT_PERCENT), + "DERIVATIVE": (PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, UNIT_PERCENT), + "HEAT": (PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, UNIT_PERCENT), + "COOL": (PIDClimateSensorType.PID_SENSOR_TYPE_COOL, UNIT_PERCENT), + "KP": (PIDClimateSensorType.PID_SENSOR_TYPE_KP, UNIT_PERCENT), + "KI": (PIDClimateSensorType.PID_SENSOR_TYPE_KI, UNIT_PERCENT), + "KD": (PIDClimateSensorType.PID_SENSOR_TYPE_KD, UNIT_PERCENT), + "DEADBAND_THRESHOLD_HIGH": ( + PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH, + UNIT_CELSIUS, + ), + "DEADBAND_THRESHOLD_LOW": ( + PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW, + UNIT_CELSIUS, + ), + "KP_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), + "KI_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), + "KD_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), } -CONFIG_SCHEMA = ( +PID_CLIMATE_SENSOR_ENUMS = { + sensor_type: sensor_config[0] + for sensor_type, sensor_config in PID_CLIMATE_SENSOR_TYPES.items() +} + + +def set_default_unit_of_measurement(config: ConfigType) -> ConfigType: + sensor_type = config[CONF_TYPE] + config.setdefault( + CONF_UNIT_OF_MEASUREMENT, PID_CLIMATE_SENSOR_TYPES[sensor_type][1] + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( PIDClimateSensor, - unit_of_measurement=UNIT_PERCENT, icon=ICON_GAUGE, accuracy_decimals=1, state_class=STATE_CLASS_MEASUREMENT, @@ -34,10 +75,11 @@ CONFIG_SCHEMA = ( .extend( { cv.GenerateID(CONF_CLIMATE_ID): cv.use_id(PIDClimate), - cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_TYPES, upper=True), + cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_ENUMS, upper=True), } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + set_default_unit_of_measurement, ) diff --git a/esphome/components/pid/sensor/pid_climate_sensor.cpp b/esphome/components/pid/sensor/pid_climate_sensor.cpp index 4e963168e68..afc77efde33 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.cpp +++ b/esphome/components/pid/sensor/pid_climate_sensor.cpp @@ -14,43 +14,55 @@ void PIDClimateSensor::update_from_parent_() { float value; switch (this->type_) { case PID_SENSOR_TYPE_RESULT: - value = this->parent_->get_output_value(); + value = this->parent_->get_output_value() * 100.0f; break; case PID_SENSOR_TYPE_ERROR: - value = this->parent_->get_error_value(); + value = this->parent_->get_error_value() * 100.0f; break; case PID_SENSOR_TYPE_PROPORTIONAL: - value = this->parent_->get_proportional_term(); + value = this->parent_->get_proportional_term() * 100.0f; break; case PID_SENSOR_TYPE_INTEGRAL: - value = this->parent_->get_integral_term(); + value = this->parent_->get_integral_term() * 100.0f; break; case PID_SENSOR_TYPE_DERIVATIVE: - value = this->parent_->get_derivative_term(); + value = this->parent_->get_derivative_term() * 100.0f; break; case PID_SENSOR_TYPE_HEAT: - value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f); + value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f; break; case PID_SENSOR_TYPE_COOL: - value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f); + value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f; break; case PID_SENSOR_TYPE_KP: value = this->parent_->get_kp(); - this->publish_state(value); - return; + break; case PID_SENSOR_TYPE_KI: value = this->parent_->get_ki(); - this->publish_state(value); - return; + break; case PID_SENSOR_TYPE_KD: value = this->parent_->get_kd(); - this->publish_state(value); - return; + break; + case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH: + value = this->parent_->get_threshold_high(); + break; + case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW: + value = this->parent_->get_threshold_low(); + break; + case PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER: + value = this->parent_->get_kp_multiplier(); + break; + case PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER: + value = this->parent_->get_ki_multiplier(); + break; + case PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER: + value = this->parent_->get_kd_multiplier(); + break; default: value = NAN; break; } - this->publish_state(value * 100.0f); + this->publish_state(value); } void PIDClimateSensor::dump_config() { LOG_SENSOR("", "PID Climate Sensor", this); } diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index b62d5977807..1d208c44f89 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -16,6 +16,11 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KP, PID_SENSOR_TYPE_KI, PID_SENSOR_TYPE_KD, + PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH, + PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW, + PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER, + PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER, + PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER, }; class PIDClimateSensor final : public sensor::Sensor, public Component { diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 62e6d0f113a..e3c841d1dde 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VALUE -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -12,7 +10,6 @@ from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns DEPENDENCIES = ["pipsolar"] PipsolarOutput = pipsolar_ns.class_("PipsolarOutput", output.FloatOutput) -SetOutputAction = pipsolar_ns.class_("SetOutputAction", automation.Action) CONF_POSSIBLE_VALUES = "possible_values" @@ -92,25 +89,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_possible_values(conf[CONF_POSSIBLE_VALUES])) -@automation.register_action( +automation.register_apply_action( "output.pipsolar.set_level", - SetOutputAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PipsolarOutput), cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -async def output_pipsolar_set_level_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_level(template_)) - return var diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 6fc013c2765..bf82fa63a81 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -27,16 +27,4 @@ class PipsolarOutput final : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction final : public Action { - public: - SetOutputAction(PipsolarOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_value(this->level_.value(x...)); } - - protected: - PipsolarOutput *output_; -}; - } // namespace esphome::pipsolar diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 4436b1fb174..aa035be347d 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) { } void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; const int side = static_cast(this->model_); switch (this->rotation_) { diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 15f5d3e8793..0b5c72a94d2 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -88,7 +88,11 @@ void PMSA003IComponent::update() { bool PMSA003IComponent::read_data_(PM25AQIData *data) { uint8_t buffer[COUNT_DATA_BYTES]; - this->read_bytes_raw(buffer, COUNT_DATA_BYTES); + const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES); + if (error != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C error %d", error); + return false; + } // https://github.com/adafruit/Adafruit_PM25AQI diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index dc853802032..5eac5415ed3 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -323,64 +323,21 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_type(config[CONF_TYPE])) - if CONF_PM_1_0_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0_STD]) - cg.add(var.set_pm_1_0_std_sensor(sens)) - - if CONF_PM_2_5_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5_STD]) - cg.add(var.set_pm_2_5_std_sensor(sens)) - - if CONF_PM_10_0_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0_STD]) - cg.add(var.set_pm_10_0_std_sensor(sens)) - - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PM_0_3UM in config: - sens = await sensor.new_sensor(config[CONF_PM_0_3UM]) - cg.add(var.set_pm_particles_03um_sensor(sens)) - - if CONF_PM_0_5UM in config: - sens = await sensor.new_sensor(config[CONF_PM_0_5UM]) - cg.add(var.set_pm_particles_05um_sensor(sens)) - - if CONF_PM_1_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0UM]) - cg.add(var.set_pm_particles_10um_sensor(sens)) - - if CONF_PM_2_5UM in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5UM]) - cg.add(var.set_pm_particles_25um_sensor(sens)) - - if CONF_PM_5_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_5_0UM]) - cg.add(var.set_pm_particles_50um_sensor(sens)) - - if CONF_PM_10_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0UM]) - cg.add(var.set_pm_particles_100um_sensor(sens)) - - if CONF_FORMALDEHYDE in config: - sens = await sensor.new_sensor(config[CONF_FORMALDEHYDE]) - cg.add(var.set_formaldehyde_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor) + await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor) + await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PM_0_3UM, var.set_pm_particles_03um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_particles_05um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_particles_10um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_particles_25um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_particles_50um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_particles_100um_sensor) + await sensors(CONF_FORMALDEHYDE, var.set_formaldehyde_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index 4ce4a5ce9c7..8ca995fbe7d 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -1,5 +1,4 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -32,35 +31,4 @@ class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction final : public Action { - public: - PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->air_calibration(); } - - protected: - PMWCS3Component *parent_; -}; - -template class PMWCS3WaterCalibrationAction final : public Action { - public: - PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->water_calibration(); } - - protected: - PMWCS3Component *parent_; -}; - -template class PMWCS3NewI2cAddressAction final : public Action { - public: - PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(int, new_address) - - void play(const Ts &...x) override { this->parent_->new_i2c_address(this->new_address_.value(x...)); } - - protected: - PMWCS3Component *parent_; -}; - } // namespace esphome::pmwcs3 diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index ae22b3e0d6c..61f89afa41b 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -10,8 +10,6 @@ from esphome.const import ( ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@SeByDocKy"] @@ -29,17 +27,6 @@ PMWCS3Component = pmwcs3_ns.class_( "PMWCS3Component", cg.PollingComponent, i2c.I2CDevice ) -# Actions -PMWCS3AirCalibrationAction = pmwcs3_ns.class_( - "PMWCS3AirCalibrationAction", automation.Action -) -PMWCS3WaterCalibrationAction = pmwcs3_ns.class_( - "PMWCS3WaterCalibrationAction", automation.Action -) -PMWCS3NewI2cAddressAction = pmwcs3_ns.class_( - "PMWCS3NewI2cAddressAction", automation.Action -) - CONFIG_SCHEMA = ( cv.Schema( { @@ -80,21 +67,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_E25 in config: - sens = await sensor.new_sensor(config[CONF_E25]) - cg.add(var.set_e25_sensor(sens)) - - if CONF_EC in config: - sens = await sensor.new_sensor(config[CONF_EC]) - cg.add(var.set_ec_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_VWC in config: - sens = await sensor.new_sensor(config[CONF_VWC]) - cg.add(var.set_vwc_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_E25, var.set_e25_sensor) + await sensors(CONF_EC, var.set_ec_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_VWC, var.set_vwc_sensor) # Actions @@ -104,28 +81,17 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "pmwcs3.air_calibration", - PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, - synchronous=True, + automation.ApplyCall("air_calibration()"), ) -@automation.register_action( - "pmwcs3.water_calibration", - PMWCS3WaterCalibrationAction, - PMWCS3_CALIBRATION_SCHEMA, - synchronous=True, -) -async def pmwcs3_calibration_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) +automation.register_apply_action( + "pmwcs3.water_calibration", + PMWCS3_CALIBRATION_SCHEMA, + automation.ApplyCall("water_calibration()"), +) PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( { @@ -135,21 +101,8 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( key=CONF_ADDRESS, ) - -@automation.register_action( +automation.register_apply_action( "pmwcs3.new_i2c_address", - PMWCS3NewI2cAddressAction, PMWCS3_NEW_I2C_ADDRESS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "new_i2c_address", cg.uint8), ) -async def pmwcs3newi2caddress_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) - cg.add(var.set_new_address(address)) - return var diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 62589323121..c9118834c56 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -3,14 +3,12 @@ import esphome.codegen as cg from esphome.components import nfc import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_ON_FINISHED_WRITE, CONF_ON_TAG, CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] @@ -22,10 +20,6 @@ CONF_PN532_ID = "pn532_id" pn532_ns = cg.esphome_ns.namespace("pn532") PN532 = pn532_ns.class_("PN532", cg.PollingComponent) -PN532IsWritingCondition = pn532_ns.class_( - "PN532IsWritingCondition", automation.Condition -) - PN532_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(PN532), @@ -52,6 +46,11 @@ def CONFIG_SCHEMA(conf: ConfigType) -> None: ) +_request_ontag_trigger_slot = cg.slot_counter("PN532_ON_TAG_TRIGGER_COUNT") +_request_ontagremoved_trigger_slot = cg.slot_counter( + "PN532_ON_TAG_REMOVED_TRIGGER_COUNT" +) + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" @@ -64,36 +63,29 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf ) for conf in config.get(CONF_ON_TAG_REMOVED, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf ) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_apply_condition( "pn532.is_writing", - PN532IsWritingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(PN532), } ), + "is_writing()", ) -async def pn532_is_writing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index 8f490ba7d0f..b9cf9a1c279 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -34,6 +34,8 @@ def validate_uid(value: Any) -> str: PN532BinarySensor = pn532_ns.class_("PN532BinarySensor", binary_sensor.BinarySensor) +_request_binary_sensor_slot = cg.slot_counter("PN532_BINARY_SENSOR_COUNT") + CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( { cv.GenerateID(CONF_PN532_ID): cv.use_id(PN532), @@ -46,6 +48,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) + _request_binary_sensor_slot(str(hub)) cg.add(hub.register_tag(var)) addr = [HexInt(int(x, 16)) for x in config[CONF_UID].split("-")] cg.add(var.set_uid(addr)) diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 8ef77217262..89392105a24 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -1,6 +1,9 @@ #include "pn532.h" +#include +#include #include +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -24,8 +27,9 @@ void PN532::setup() { } } - std::vector version_data; - if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data)) { + PN532Frame version_data; + // GetFirmwareVersion returns IC, Ver, Rev and Support + if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data) || version_data.size() < 3) { ESP_LOGE(TAG, "Error getting version"); this->mark_failed(); return; @@ -35,15 +39,15 @@ void PN532::setup() { if (!this->write_command_({ PN532_COMMAND_SAMCONFIGURATION, 0x01, // normal mode - 0x14, // zero timeout (not in virtual card mode) - 0x01, + 0x14, // timeout: 20 x 50 ms (only used in virtual card mode) + 0x01, // use IRQ })) { ESP_LOGE(TAG, "No wakeup ack"); this->mark_failed(); return; } - std::vector wakeup_result; + PN532Frame wakeup_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, wakeup_result)) { this->error_code_ = WAKEUP_FAILED; this->mark_failed(); @@ -63,7 +67,7 @@ void PN532::setup() { return; } - std::vector sam_result; + PN532Frame sam_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, sam_result)) { ESP_LOGV(TAG, "Invalid SAM result: (%u)", sam_result.size()); // NOLINT for (uint8_t dat : sam_result) { @@ -85,13 +89,13 @@ bool PN532::powerdown() { ESP_LOGE(TAG, "Error writing powerdown command to PN532"); return false; } - std::vector response; + PN532Frame response; if (!this->read_response(PN532_COMMAND_POWERDOWN, response)) { ESP_LOGE(TAG, "Error reading PN532 powerdown response"); return false; } - if (response[0] != 0x00) { - ESP_LOGE(TAG, "Error on PN532 powerdown: %02x", response[0]); + if (response.empty() || response[0] != 0x00) { + ESP_LOGE(TAG, "Powerdown error: %02x", response.empty() ? 0xFF : response[0]); return false; } ESP_LOGV(TAG, "Powerdown successful"); @@ -103,8 +107,10 @@ void PN532::update() { if (!updates_enabled_) return; +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *obj : this->binary_sensors_) obj->on_scan_end(); +#endif if (!this->write_command_({ PN532_COMMAND_INLISTPASSIVETARGET, @@ -128,7 +134,7 @@ void PN532::loop() { return; bool success = false; - std::vector read; + PN532Frame read; if (ready == READY) { success = this->read_response(PN532_COMMAND_INLISTPASSIVETARGET, read); @@ -140,42 +146,56 @@ void PN532::loop() { if (!success) { // Something failed +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT if (!this->current_uid_.empty()) { auto tag = make_unique(this->current_uid_); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(tag); } +#endif this->current_uid_ = {}; this->turn_off_rf_(); return; } - uint8_t num_targets = read[0]; + uint8_t num_targets = read.empty() ? 0 : read[0]; if (num_targets != 1) { // no tags found or too many +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT if (!this->current_uid_.empty()) { auto tag = make_unique(this->current_uid_); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(tag); } +#endif this->current_uid_ = {}; this->turn_off_rf_(); return; } + // target data for 106 kbps type A: NbTg, Tg, SENS_RES (2 bytes), SEL_RES, NFCIDLength, NFCID1 (UM0701-02, 7.3.5) + if (read.size() < 6) { + this->turn_off_rf_(); + return; + } + const uint8_t sel_res = read[4]; uint8_t nfcid_length = read[5]; - if (nfcid_length > nfc::NFC_UID_MAX_LENGTH || read.size() < 6U + nfcid_length) { + if (nfcid_length == 0 || nfcid_length > nfc::NFC_UID_MAX_LENGTH || read.size() < 6U + nfcid_length) { // oops, pn532 returned invalid data + this->turn_off_rf_(); return; } nfc::NfcTagUid nfcid(read.begin() + 6, read.begin() + 6 + nfcid_length); + const uint8_t tag_type = tag_type_from_sel_res(sel_res); bool report = true; +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *bin_sens : this->binary_sensors_) { if (bin_sens->process(nfcid)) { report = false; } } +#endif if (nfcid.size() == this->current_uid_.size()) { bool same_uid = true; @@ -188,9 +208,11 @@ void PN532::loop() { this->current_uid_ = nfcid; if (next_task_ == READ) { - auto tag = this->read_tag_(nfcid); + auto tag = this->read_tag_(nfcid, tag_type); +#ifdef PN532_ON_TAG_TRIGGER_COUNT for (auto *trigger : this->triggers_ontag_) trigger->process(tag); +#endif if (report) { char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; @@ -206,13 +228,13 @@ void PN532::loop() { } } else if (next_task_ == CLEAN) { ESP_LOGD(TAG, " Tag cleaning"); - if (!this->clean_tag_(nfcid)) { + if (!this->clean_tag_(nfcid, tag_type)) { ESP_LOGE(TAG, " Tag was not fully cleaned successfully"); } ESP_LOGD(TAG, " Tag cleaned!"); } else if (next_task_ == FORMAT) { ESP_LOGD(TAG, " Tag formatting"); - if (!this->format_tag_(nfcid)) { + if (!this->format_tag_(nfcid, tag_type)) { ESP_LOGE(TAG, "Error formatting tag as NDEF"); } ESP_LOGD(TAG, " Tag formatted!"); @@ -220,16 +242,15 @@ void PN532::loop() { if (this->next_task_message_to_write_ != nullptr) { ESP_LOGD(TAG, " Tag writing"); ESP_LOGD(TAG, " Tag formatting"); - if (!this->format_tag_(nfcid)) { + if (!this->format_tag_(nfcid, tag_type)) { ESP_LOGE(TAG, " Tag could not be formatted for writing"); } else { ESP_LOGD(TAG, " Writing NDEF data"); - if (!this->write_tag_(nfcid, this->next_task_message_to_write_)) { + if (!this->write_tag_(nfcid, tag_type, this->next_task_message_to_write_.get())) { ESP_LOGE(TAG, " Failed to write message to tag"); } ESP_LOGD(TAG, " Finished writing NDEF data"); - delete this->next_task_message_to_write_; - this->next_task_message_to_write_ = nullptr; + this->next_task_message_to_write_.reset(); this->on_finished_write_callback_.call(); } } @@ -240,39 +261,42 @@ void PN532::loop() { this->turn_off_rf_(); } -bool PN532::write_command_(const std::vector &data) { - std::vector write_data; +bool PN532::write_command_(const std::span data) { + if (data.size() > PN532_FRAME_MAX_DATA_SIZE) { + return false; + } + PN532Frame frame; // Preamble - write_data.push_back(0x00); + frame.push_back(0x00); // Start code - write_data.push_back(0x00); - write_data.push_back(0xFF); + frame.push_back(0x00); + frame.push_back(0xFF); // Length of message, TFI + data bytes const uint8_t real_length = data.size() + 1; // LEN - write_data.push_back(real_length); + frame.push_back(real_length); // LCS (Length checksum) - write_data.push_back(~real_length + 1); + frame.push_back(~real_length + 1); // TFI (Frame Identifier, 0xD4 means to PN532, 0xD5 means from PN532) - write_data.push_back(0xD4); + frame.push_back(0xD4); // calculate checksum, TFI is part of checksum uint8_t checksum = 0xD4; // DATA for (uint8_t dat : data) { - write_data.push_back(dat); + frame.push_back(dat); checksum += dat; } // DCS (Data checksum) - write_data.push_back(~checksum + 1); + frame.push_back(~checksum + 1); // Postamble - write_data.push_back(0x00); + frame.push_back(0x00); - this->write_data(write_data); + this->write_data(frame); return this->read_ack_(); } @@ -280,8 +304,8 @@ bool PN532::write_command_(const std::vector &data) { bool PN532::read_ack_() { ESP_LOGV(TAG, "Reading ACK"); - std::vector data; - if (!this->read_data(data, 6)) { + PN532Frame data; + if (!this->read_data(data, 6) || data.size() < 7) { return false; } @@ -294,29 +318,33 @@ bool PN532::read_ack_() { } void PN532::send_ack_() { + static constexpr std::array ACK_FRAME = {0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; ESP_LOGV(TAG, "Sending ACK for abort"); - this->write_data({0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}); + this->write_data(ACK_FRAME); delay(10); } void PN532::send_nack_() { + static constexpr std::array NACK_FRAME = {0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}; ESP_LOGV(TAG, "Sending NACK for retransmit"); - this->write_data({0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}); + this->write_data(NACK_FRAME); delay(10); } enum PN532ReadReady PN532::read_ready_(bool block) { if (this->rd_ready_ == READY) { if (block) { - this->rd_start_time_.reset(); + this->rd_started_ = false; this->rd_ready_ = WOULDBLOCK; } return READY; } - if (!this->rd_start_time_.has_value()) { - this->rd_start_time_ = millis(); + // the non-blocking call comes from loop(), where the tick's timestamp is already cached + if (!this->rd_started_) { + this->rd_start_time_ = block ? millis() : App.get_loop_component_start_time(); + this->rd_started_ = true; } - const uint32_t rd_start_time = *this->rd_start_time_; + const uint32_t rd_start_time = this->rd_start_time_; while (true) { if (this->is_read_ready()) { @@ -324,7 +352,8 @@ enum PN532ReadReady PN532::read_ready_(bool block) { break; } - if (millis() - rd_start_time > 100) { + const uint32_t now = block ? millis() : App.get_loop_component_start_time(); + if (now - rd_start_time > 100) { ESP_LOGV(TAG, "Timed out waiting for readiness from PN532!"); this->rd_ready_ = TIMEOUT; break; @@ -340,7 +369,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) { auto rdy = this->rd_ready_; if (block || rdy == TIMEOUT) { - this->rd_start_time_.reset(); + this->rd_started_ = false; this->rd_ready_ = WOULDBLOCK; } return rdy; @@ -355,21 +384,16 @@ void PN532::turn_off_rf_() { }); } -std::unique_ptr PN532::read_tag_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +std::unique_ptr PN532::read_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { ESP_LOGD(TAG, "Mifare classic"); return this->read_mifare_classic_tag_(uid); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { ESP_LOGD(TAG, "Mifare ultralight"); return this->read_mifare_ultralight_tag_(uid); - } else if (type == nfc::TAG_TYPE_UNKNOWN) { - ESP_LOGV(TAG, "Cannot determine tag type"); - return make_unique(uid); - } else { - return make_unique(uid); } + ESP_LOGV(TAG, "Reading tag type %u is not supported", tag_type); + return make_unique(uid); } void PN532::read_mode() { @@ -386,43 +410,76 @@ void PN532::format_mode() { } void PN532::write_mode(nfc::NdefMessage *message) { this->next_task_ = WRITE; - this->next_task_message_to_write_ = message; + this->next_task_message_to_write_.reset(message); ESP_LOGD(TAG, "Waiting to write next tag"); } -bool PN532::clean_tag_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +bool PN532::clean_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { return this->format_mifare_classic_mifare_(uid); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { return this->clean_mifare_ultralight_(); } ESP_LOGE(TAG, "Unsupported Tag for formatting"); return false; } -bool PN532::format_tag_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +bool PN532::format_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { return this->format_mifare_classic_ndef_(uid); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { return this->clean_mifare_ultralight_(); } ESP_LOGE(TAG, "Unsupported Tag for formatting"); return false; } -bool PN532::write_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { - uint8_t type = nfc::guess_tag_type(uid.size()); - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +bool PN532::write_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type, nfc::NdefMessage *message) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { return this->write_mifare_classic_tag_(uid, message); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { return this->write_mifare_ultralight_tag_(uid, message); } - ESP_LOGE(TAG, "Unsupported Tag for formatting"); + ESP_LOGE(TAG, "Unsupported Tag for writing"); return false; } +bool PN532::in_data_exchange_(const std::span command, PN532Frame &response) { + // formatting a tag takes seconds of back-to-back exchanges inside loop(), longer than the task watchdog allows + App.feed_wdt(); + if (!this->write_command_(command)) { + return false; + } + // output: Status, DataIn; a status of 0x00 means the exchange with the target succeeded (UM0701-02, 7.3.8) + if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response) || response.empty()) { + return false; + } + if (response[0] != 0x00) { + ESP_LOGV(TAG, "InDataExchange failed, status 0x%02X", response[0]); + return false; + } + std::copy(response.begin() + 1, response.end(), response.begin()); + response.resize(response.size() - 1); + return true; +} + +bool PN532::mifare_read_(uint8_t address, MifareReadData &data) { + PN532Frame response; + if (!this->in_data_exchange_( + { + PN532_COMMAND_INDATAEXCHANGE, + 0x01, // One card + nfc::MIFARE_CMD_READ, + address, + }, + response) || + response.size() != data.size()) { + return false; + } + std::copy(response.begin(), response.end(), data.begin()); + return true; +} + void PN532::dump_config() { ESP_LOGCONFIG(TAG, "PN532:"); switch (this->error_code_) { @@ -438,9 +495,11 @@ void PN532::dump_config() { LOG_UPDATE_INTERVAL(this); +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *child : this->binary_sensors_) { LOG_BINARY_SENSOR(" ", "Tag", child); } +#endif } bool PN532BinarySensor::process(const nfc::NfcTagUid &data) { diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 629a697aa59..5502eac6ec8 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -1,14 +1,16 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/nfc/nfc_tag.h" #include "esphome/components/nfc/nfc.h" #include "esphome/components/nfc/automation.h" +#include #include -#include +#include +#include namespace esphome::pn532 { @@ -19,12 +21,43 @@ static const uint8_t PN532_COMMAND_INDATAEXCHANGE = 0x40; static const uint8_t PN532_COMMAND_INLISTPASSIVETARGET = 0x4A; static const uint8_t PN532_COMMAND_POWERDOWN = 0x16; -enum PN532ReadReady { +enum PN532ReadReady : uint8_t { WOULDBLOCK = 0, TIMEOUT, READY, }; +// SEL_RES (SAK) bits, as reported by InListPassiveTarget for ISO/IEC 14443 type A targets (NXP AN10833) +static constexpr uint8_t SEL_RES_MIFARE_CLASSIC = 0x08; +static constexpr uint8_t SEL_RES_ISO_DEP = 0x20; +static constexpr uint8_t SEL_RES_TNP3XXX = 0x01; // MIFARE Classic 1K compatible + +/// Tag type (nfc::TAG_TYPE_*) from a type A target's SEL_RES byte +inline uint8_t tag_type_from_sel_res(uint8_t sel_res) { + if ((sel_res & SEL_RES_MIFARE_CLASSIC) || sel_res == SEL_RES_TNP3XXX) + return nfc::TAG_TYPE_MIFARE_CLASSIC; + if (sel_res & SEL_RES_ISO_DEP) + return nfc::TAG_TYPE_4; + if (sel_res == 0x00) + return nfc::TAG_TYPE_2; + return nfc::TAG_TYPE_UNKNOWN; +} + +/// Most data bytes a normal information frame carries: LEN is one byte and counts the TFI byte plus the data +static constexpr size_t PN532_FRAME_MAX_DATA_SIZE = 254; +/// A received frame at its longest: the status byte the I2C bus prepends, preamble, start code (2), LEN, LCS, TFI, +/// the data, DCS and postamble +static constexpr size_t PN532_FRAME_MAX_SIZE = 1 + 6 + PN532_FRAME_MAX_DATA_SIZE + 2; +/// Holds one frame in either direction so bus traffic never allocates +using PN532Frame = StaticVector; +/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads +using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) +static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; +/// A MIFARE READ answers with 16 bytes: one Classic block or four Ultralight pages +static constexpr size_t MIFARE_READ_SIZE = 16; +using MifareReadData = std::array; + class PN532BinarySensor; class PN532 : public PollingComponent { @@ -38,9 +71,15 @@ class PN532 : public PollingComponent { void loop() override; void on_powerdown() override { powerdown(); } +#ifdef PN532_BINARY_SENSOR_COUNT void register_tag(PN532BinarySensor *tag) { this->binary_sensors_.push_back(tag); } +#endif +#ifdef PN532_ON_TAG_TRIGGER_COUNT void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } +#endif +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } +#endif template void add_on_finished_write_callback(F &&callback) { this->on_finished_write_callback_.add(std::forward(callback)); @@ -56,62 +95,86 @@ class PN532 : public PollingComponent { protected: void turn_off_rf_(); - bool write_command_(const std::vector &data); + bool write_command_(std::span data); + bool write_command_(std::initializer_list data) { + return this->write_command_(std::span(data.begin(), data.size())); + } bool read_ack_(); void send_ack_(); void send_nack_(); enum PN532ReadReady read_ready_(bool block); virtual bool is_read_ready() = 0; - virtual bool write_data(const std::vector &data) = 0; - virtual bool read_data(std::vector &data, uint8_t len) = 0; - virtual bool read_response(uint8_t command, std::vector &data) = 0; + virtual bool write_data(std::span data) = 0; + /// Reads `len` frame bytes into `data` behind a leading status byte, so every bus presents the I2C layout + virtual bool read_data(PN532Frame &data, size_t len) = 0; + /// Reads the response to `command`; on success `data` holds only the bytes that follow the response code + virtual bool read_response(uint8_t command, PN532Frame &data) = 0; - std::unique_ptr read_tag_(nfc::NfcTagUid &uid); + std::unique_ptr read_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); - bool format_tag_(nfc::NfcTagUid &uid); - bool clean_tag_(nfc::NfcTagUid &uid); - bool write_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); + bool format_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); + bool clean_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); + bool write_tag_(nfc::NfcTagUid &uid, uint8_t tag_type, nfc::NdefMessage *message); + /// Sends an InDataExchange command and reads the response; returns false unless the status byte reports success. + /// On success, `response` holds the data returned by the target, without the status byte. + bool in_data_exchange_(std::span command, PN532Frame &response); + bool in_data_exchange_(std::initializer_list command, PN532Frame &response) { + return this->in_data_exchange_(std::span(command.begin(), command.size()), response); + } + /// Sends MIFARE READ for `address` and returns the 16 bytes the tag answers with + bool mifare_read_(uint8_t address, MifareReadData &data); std::unique_ptr read_mifare_classic_tag_(nfc::NfcTagUid &uid); - bool read_mifare_classic_block_(uint8_t block_num, std::vector &data); - bool write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); + bool read_mifare_classic_block_(uint8_t block_num, MifareReadData &data); + bool write_mifare_classic_block_(uint8_t block_num, std::span data); bool auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, uint8_t key_num, const uint8_t *key); bool format_mifare_classic_mifare_(nfc::NfcTagUid &uid); bool format_mifare_classic_ndef_(nfc::NfcTagUid &uid); bool write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); std::unique_ptr read_mifare_ultralight_tag_(nfc::NfcTagUid &uid); - bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); + bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - bool find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, + bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index); - bool write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); + bool write_mifare_ultralight_page_(uint8_t page_num, std::span write_data); bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); bool clean_mifare_ultralight_(); - bool updates_enabled_{true}; - bool requested_read_{false}; - std::vector binary_sensors_; - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; - nfc::NfcTagUid current_uid_; - nfc::NdefMessage *next_task_message_to_write_; - optional rd_start_time_{}; - enum PN532ReadReady rd_ready_ { WOULDBLOCK }; - enum NfcTask { + enum NfcTask : uint8_t { READ = 0, CLEAN, FORMAT, WRITE, - } next_task_{READ}; - enum PN532Error { + }; + enum PN532Error : uint8_t { NONE = 0, WAKEUP_FAILED, SAM_COMMAND_FAILED, - } error_code_{NONE}; - CallbackManager on_finished_write_callback_; + }; + + // members are ordered by alignment, widest first, to minimize padding + LazyCallbackManager on_finished_write_callback_; +#ifdef PN532_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef PN532_ON_TAG_TRIGGER_COUNT + StaticVector triggers_ontag_; +#endif +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT + StaticVector triggers_ontagremoved_; +#endif + std::unique_ptr next_task_message_to_write_; + nfc::NfcTagUid current_uid_; + uint32_t rd_start_time_{0}; // valid only while rd_started_ is set + PN532ReadReady rd_ready_{WOULDBLOCK}; + NfcTask next_task_{READ}; + PN532Error error_code_{NONE}; + bool rd_started_{false}; + bool updates_enabled_{true}; + bool requested_read_{false}; }; class PN532BinarySensor final : public binary_sensor::BinarySensor { @@ -132,9 +195,4 @@ class PN532BinarySensor final : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - } // namespace esphome::pn532 diff --git a/esphome/components/pn532/pn532_mifare_classic.cpp b/esphome/components/pn532/pn532_mifare_classic.cpp index 37674080d83..9f937b3e288 100644 --- a/esphome/components/pn532/pn532_mifare_classic.cpp +++ b/esphome/components/pn532/pn532_mifare_classic.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include "pn532.h" @@ -13,10 +15,10 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid uint8_t message_start_index = 0; uint32_t message_length = 0; + MifareReadData block_data; if (this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { - std::vector data; - if (this->read_mifare_classic_block_(current_block, data)) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data)) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return make_unique(uid, nfc::ERROR); } } else { @@ -27,77 +29,79 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid ESP_LOGV(TAG, "Tag is not NDEF formatted"); return make_unique(uid, nfc::MIFARE_CLASSIC); } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (!this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { ESP_LOGE(TAG, "Error, Block authentication failed for %d", current_block); + return make_unique(uid, nfc::MIFARE_CLASSIC); } } - std::vector block_data; - if (this->read_mifare_classic_block_(current_block, block_data)) { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); - } else { + if (!this->read_mifare_classic_block_(current_block, block_data)) { ESP_LOGE(TAG, "Error reading block %d", current_block); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } + for (const uint8_t byte : block_data) { + buffer.push_back(byte); } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return make_unique(uid, nfc::MIFARE_CLASSIC); } - return make_unique(uid, nfc::MIFARE_CLASSIC, buffer); + return make_unique( + uid, nfc::MIFARE_CLASSIC, + make_unique(std::span(buffer).subspan(message_start_index))); } -bool PN532::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { - if (!this->write_command_({ - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - block_num, - })) { +bool PN532::read_mifare_classic_block_(uint8_t block_num, MifareReadData &data) { + if (!this->mifare_read_(block_num, data)) { return false; } - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, data) || data[0] != 0x00) { - return false; - } - data.erase(data.begin()); - char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; ESP_LOGVV(TAG, " Block %d: %s", block_num, nfc::format_bytes_to(data_buf, data)); return true; } bool PN532::auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, uint8_t key_num, const uint8_t *key) { - std::vector data({ + // InDataExchange, Tg, key slot, block, key (6), UID (4) + StaticVector data = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card key_num, // Mifare Key slot block_num, // Block number - }); - data.insert(data.end(), key, key + 6); - data.insert(data.end(), uid.begin(), uid.end()); - if (!this->write_command_(data)) { - ESP_LOGE(TAG, "Authentication failed - Block %d", block_num); + }; + for (size_t i = 0; i < 6; i++) { + data.push_back(key[i]); + } + // the command takes exactly 4 UID bytes (UM0701-02, 7.3.8); for 7-byte UIDs these are the last 4, as in libnfc + if (uid.size() < 4) { return false; } + for (size_t i = uid.size() - 4; i < uid.size(); i++) { + data.push_back(uid[i]); + } - std::vector response; - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response) || response[0] != 0x00) { + PN532Frame response; + if (!this->in_data_exchange_(data, response)) { ESP_LOGE(TAG, "Authentication failed - Block 0x%02x", block_num); return false; } @@ -118,20 +122,20 @@ bool PN532::format_mifare_classic_mifare_(nfc::NfcTagUid &uid) { continue; } if (block != 0) { - if (!this->write_mifare_classic_block_(block, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } - if (!this->write_mifare_classic_block_(block + 1, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 1, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); error = true; } - if (!this->write_mifare_classic_block_(block + 2, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 2, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); error = true; } - if (!this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER.data(), TRAILER_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 3); error = true; } @@ -158,56 +162,61 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { ESP_LOGE(TAG, "Unable to authenticate block 0 for formatting!"); return false; } - if (!this->write_mifare_classic_block_(1, BLOCK_1_DATA.data(), BLOCK_1_DATA.size())) + if (!this->write_mifare_classic_block_(1, BLOCK_1_DATA)) return false; - if (!this->write_mifare_classic_block_(2, BLOCK_2_DATA.data(), BLOCK_2_DATA.size())) + if (!this->write_mifare_classic_block_(2, BLOCK_2_DATA)) return false; - if (!this->write_mifare_classic_block_(3, BLOCK_3_TRAILER.data(), BLOCK_3_TRAILER.size())) + if (!this->write_mifare_classic_block_(3, BLOCK_3_TRAILER)) return false; ESP_LOGD(TAG, "Sector 0 formatted to NDEF"); + bool error = false; + for (int block = 4; block < 64; block += 4) { if (!this->auth_mifare_classic_block_(uid, block + 3, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY)) { return false; } if (block == 4) { - if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE.data(), EMPTY_NDEF_MESSAGE.size())) { + if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE)) { ESP_LOGE(TAG, "Unable to write block %d", block); + error = true; } } else { - if (!this->write_mifare_classic_block_(block, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block); + error = true; } } - if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); + error = true; } - if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); + error = true; } - if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER.data(), NDEF_TRAILER.size())) { + if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER)) { ESP_LOGE(TAG, "Unable to write trailer block %d", block + 3); + error = true; } } - return true; + return !error; } -bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { - std::vector cmd({ +bool PN532::write_mifare_classic_block_(uint8_t block_num, const std::span data) { + StaticVector cmd = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card nfc::MIFARE_CMD_WRITE, block_num, - }); - cmd.insert(cmd.end(), data, data + len); - if (!this->write_command_(cmd)) { - ESP_LOGE(TAG, "Error writing block %d", block_num); - return false; + }; + for (const uint8_t byte : data) { + cmd.push_back(byte); } - std::vector response; - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response)) { + PN532Frame response; + if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing block %d", block_num); return false; } @@ -216,22 +225,10 @@ bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, } bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -243,7 +240,8 @@ bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *mes } } - if (!this->write_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE)) { + if (!this->write_mifare_classic_block_(current_block, + std::span(&buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE))) { return false; } index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index eb3d13a7e06..90c85237224 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -9,7 +10,7 @@ namespace esphome::pn532 { static const char *const TAG = "pn532.mifare_ultralight"; std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { - std::vector data; + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (!this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data)) { @@ -40,34 +41,29 @@ std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid & return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } - return make_unique(uid, nfc::NFC_FORUM_TYPE_2, data); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2, + make_unique(std::span(data).subspan(skip))); } -bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { - const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - std::vector response; +bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { + MifareReadData chunk; - for (uint8_t i = 0; i * read_increment < num_bytes; i++) { - if (!this->write_command_({ - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), - })) { + for (uint8_t i = 0; i * MIFARE_READ_SIZE < num_bytes; i++) { + // a READ returns 4 pages (16 bytes) + if (!this->mifare_read_(uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), chunk)) { return false; } - - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response) || response[0] != 0x00) { - return false; - } - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? response.end() : response.end() - (bytes_offset - num_bytes); - - if ((pages_in_end_itr > response.begin()) && (pages_in_end_itr <= response.end())) { - data.insert(data.end(), response.begin() + 1, pages_in_end_itr); + // keep only the bytes still wanted from this read + const uint16_t remaining = num_bytes - i * MIFARE_READ_SIZE; + const size_t count = std::min(MIFARE_READ_SIZE, remaining); + for (const uint8_t byte : std::span(chunk).subspan(0, count)) { + data.push_back(byte); } } @@ -77,7 +73,7 @@ bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes return true; } -bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN532::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -86,15 +82,15 @@ bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_t } uint16_t PN532::read_mifare_ultralight_capacity_() { - std::vector data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data)) { + UltralightReadBuffer data; + if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; } return 0; } -bool PN532::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -117,33 +113,23 @@ bool PN532::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6 bool PN532::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return false; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (!this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE)) { + if (!this->write_mifare_ultralight_page_( + current_page, std::span(&buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE))) { return false; } index += nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; @@ -159,28 +145,26 @@ bool PN532::clean_mifare_ultralight_() { static constexpr std::array BLANK_DATA = {0x00, 0x00, 0x00, 0x00}; for (int i = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; i < pages; i++) { - if (!this->write_mifare_ultralight_page_(i, BLANK_DATA.data(), BLANK_DATA.size())) { + if (!this->write_mifare_ultralight_page_(i, BLANK_DATA)) { return false; } } return true; } -bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector cmd({ +bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const std::span write_data) { + StaticVector cmd = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num, - }); - cmd.insert(cmd.end(), write_data, write_data + len); - if (!this->write_command_(cmd)) { - ESP_LOGE(TAG, "Error writing page %u", page_num); - return false; + }; + for (const uint8_t byte : write_data) { + cmd.push_back(byte); } - std::vector response; - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response)) { + PN532Frame response; + if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing page %u", page_num); return false; } diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 7f4d78461be..7f00e18d4c0 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + // Based on: // - https://cdn-shop.adafruit.com/datasheets/PN532C106_Application+Note_v1.2.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf @@ -12,30 +14,34 @@ namespace esphome::pn532_i2c { static const char *const TAG = "pn532_i2c"; bool PN532I2C::is_read_ready() { - uint8_t ready; - if (!this->read_bytes_raw(&ready, 1)) { + uint8_t status; + if (!this->read_bytes_raw(&status, 1)) { return false; } - return ready == 0x01; + // only bit 0 (RDY) of the status byte is defined (UM0701-02, 6.2.4) + return status & 0x01; } -bool PN532I2C::write_data(const std::vector &data) { +bool PN532I2C::write_data(const std::span data) { return this->write(data.data(), data.size()) == i2c::ERROR_OK; } -bool PN532I2C::read_data(std::vector &data, uint8_t len) { +bool PN532I2C::read_data(pn532::PN532Frame &data, size_t len) { + if (len + 1 > pn532::PN532_FRAME_MAX_SIZE) { + return false; + } delay(1); if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { return false; } + // the PN532 prefixes every frame with a status byte data.resize(len + 1); - this->read_bytes_raw(data.data(), len + 1); - return true; + return this->read_bytes_raw(data.data(), len + 1); } -bool PN532I2C::read_response(uint8_t command, std::vector &data) { +bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Reading response"); uint8_t len = this->read_response_length_(); if (len == 0) { @@ -63,33 +69,33 @@ bool PN532I2C::read_response(uint8_t command, std::vector &data) { return false; } - data.erase(data.begin(), data.begin() + 6); // Remove headers - + // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble + constexpr size_t tfi_offset = 6; uint8_t checksum = 0; - for (int i = 0; i < len + 1; i++) { - uint8_t dat = data[i]; - checksum += dat; + for (size_t i = 0; i < len + 1U; i++) { + checksum += data[tfi_offset + i]; } checksum = ~checksum + 1; - if (data[len + 1] != checksum) { - ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len], checksum); + if (data[tfi_offset + len + 1] != checksum) { + ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); return false; } - if (data[len + 2] != 0x00) { + if (data[tfi_offset + len + 2] != 0x00) { ESP_LOGV(TAG, "read data invalid postamble!"); return false; } - data.erase(data.begin(), data.begin() + 2); // Remove TFI and command code - data.erase(data.end() - 2, data.end()); // Remove checksum and postamble + // keep only the data bytes that follow the command response code + std::copy(data.begin() + tfi_offset + 2, data.begin() + tfi_offset + len + 1, data.begin()); + data.resize(len - 1); return true; } uint8_t PN532I2C::read_response_length_() { - std::vector data; + pn532::PN532Frame data; if (!this->read_data(data, 6)) { return 0; } diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index 6495f175999..f148e196a02 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -4,7 +4,7 @@ #include "esphome/components/pn532/pn532.h" #include "esphome/components/i2c/i2c.h" -#include +#include namespace esphome::pn532_i2c { @@ -14,9 +14,9 @@ class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { protected: bool is_read_ready() override; - bool write_data(const std::vector &data) override; - bool read_data(std::vector &data, uint8_t len) override; - bool read_response(uint8_t command, std::vector &data) override; + bool write_data(std::span data) override; + bool read_data(pn532::PN532Frame &data, size_t len) override; + bool read_response(uint8_t command, pn532::PN532Frame &data) override; uint8_t read_response_length_(); }; diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 13d9aebc20c..73d65e4330d 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -2,6 +2,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + // Based on: // - https://cdn-shop.adafruit.com/datasheets/PN532C106_Application+Note_v1.2.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf @@ -25,12 +27,13 @@ void PN532Spi::setup() { bool PN532Spi::is_read_ready() { this->enable(); this->write_byte(0x02); - bool ready = this->read_byte() == 0x01; + // only bit 0 (RDY) of the status byte is defined (UM0701-02, 6.2.5) + const bool ready = this->read_byte() & 0x01; this->disable(); return ready; } -bool PN532Spi::write_data(const std::vector &data) { +bool PN532Spi::write_data(const std::span data) { this->enable(); delay(2); // First byte, communication mode: Write data @@ -45,8 +48,8 @@ bool PN532Spi::write_data(const std::vector &data) { return true; } -bool PN532Spi::read_data(std::vector &data, uint8_t len) { - if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { +bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { + if (len + 1 > pn532::PN532_FRAME_MAX_SIZE || this->read_ready_(true) != pn532::PN532ReadReady::READY) { return false; } @@ -57,10 +60,11 @@ bool PN532Spi::read_data(std::vector &data, uint8_t len) { ESP_LOGV(TAG, "Reading data"); - data.resize(len); - this->read_array(data.data(), len); + // lead with a status byte so callers see the same layout as on the I2C bus + data.resize(len + 1); + data[0] = 0x01; + this->read_array(data.data() + 1, len); this->disable(); - data.insert(data.begin(), 0x01); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; #endif @@ -68,7 +72,7 @@ bool PN532Spi::read_data(std::vector &data, uint8_t len) { return true; } -bool PN532Spi::read_response(uint8_t command, std::vector &data) { +bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Reading response"); if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { @@ -79,8 +83,8 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { delay(2); this->write_byte(0x03); - std::vector header(7); - this->read_array(header.data(), 7); + std::array header; + this->read_array(header.data(), header.size()); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; @@ -140,7 +144,7 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { return false; } - data.erase(data.end() - 2, data.end()); // Remove checksum and postamble + data.resize(len - 1); // Remove checksum and postamble return true; } diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index f29950c423c..b051cd781e5 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -4,7 +4,7 @@ #include "esphome/components/pn532/pn532.h" #include "esphome/components/spi/spi.h" -#include +#include namespace esphome::pn532_spi { @@ -18,9 +18,9 @@ class PN532Spi final : public pn532::PN532, protected: bool is_read_ready() override; - bool write_data(const std::vector &data) override; - bool read_data(std::vector &data, uint8_t len) override; - bool read_response(uint8_t command, std::vector &data) override; + bool write_data(std::span data) override; + bool read_data(pn532::PN532Frame &data, size_t len) override; + bool read_response(uint8_t command, pn532::PN532Frame &data) override; }; } // namespace esphome::pn532_spi diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 4638992abf3..14299ee310a 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -1,240 +1,23 @@ -from esphome import automation, pins -from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components import nfc +from esphome.components import pn71xx import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_IRQ_PIN, - CONF_MESSAGE, - CONF_ON_FINISHED_WRITE, - CONF_ON_TAG, - CONF_ON_TAG_REMOVED, - CONF_TRIGGER_ID, -) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType -AUTO_LOAD = ["binary_sensor", "nfc"] +AUTO_LOAD = ["pn71xx"] CODEOWNERS = ["@kbx81", "@jesserockz"] -CONF_EMULATION_MESSAGE = "emulation_message" -CONF_EMULATION_OFF = "emulation_off" -CONF_EMULATION_ON = "emulation_on" -CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" -CONF_ON_EMULATED_TAG_SCAN = "on_emulated_tag_scan" -CONF_PN7150_ID = "pn7150_id" -CONF_POLLING_OFF = "polling_off" -CONF_POLLING_ON = "polling_on" -CONF_SET_CLEAN_MODE = "set_clean_mode" -CONF_SET_EMULATION_MESSAGE = "set_emulation_message" -CONF_SET_FORMAT_MODE = "set_format_mode" -CONF_SET_READ_MODE = "set_read_mode" -CONF_SET_WRITE_MESSAGE = "set_write_message" -CONF_SET_WRITE_MODE = "set_write_mode" -CONF_TAG_TTL = "tag_ttl" -CONF_VEN_PIN = "ven_pin" - pn7150_ns = cg.esphome_ns.namespace("pn7150") -PN7150 = pn7150_ns.class_("PN7150", nfc.Nfcc, cg.Component) +PN7150 = pn7150_ns.class_("PN7150", pn71xx.PN71xx) -EmulationOffAction = pn7150_ns.class_("EmulationOffAction", automation.Action) -EmulationOnAction = pn7150_ns.class_("EmulationOnAction", automation.Action) -PollingOffAction = pn7150_ns.class_("PollingOffAction", automation.Action) -PollingOnAction = pn7150_ns.class_("PollingOnAction", automation.Action) -SetCleanModeAction = pn7150_ns.class_("SetCleanModeAction", automation.Action) -SetEmulationMessageAction = pn7150_ns.class_( - "SetEmulationMessageAction", automation.Action -) -SetFormatModeAction = pn7150_ns.class_("SetFormatModeAction", automation.Action) -SetReadModeAction = pn7150_ns.class_("SetReadModeAction", automation.Action) -SetWriteMessageAction = pn7150_ns.class_("SetWriteMessageAction", automation.Action) -SetWriteModeAction = pn7150_ns.class_("SetWriteModeAction", automation.Action) - - -PN7150IsWritingCondition = pn7150_ns.class_( - "PN7150IsWritingCondition", automation.Condition -) - - -IsWritingCondition = nfc.nfc_ns.class_("IsWritingCondition", automation.Condition) - - -SIMPLE_ACTION_SCHEMA = maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(PN7150), - } -) - -SET_MESSAGE_ACTION_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7150), - cv.Required(CONF_MESSAGE): cv.templatable(cv.string), - cv.Optional(CONF_INCLUDE_ANDROID_APP_RECORD, default=True): cv.boolean, - } -) - -PN7150_SCHEMA = cv.Schema( +PN7150_SCHEMA = pn71xx.PN71XX_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(PN7150), - cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), - cv.Optional(CONF_ON_TAG): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), - cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), - cv.Required(CONF_IRQ_PIN): pins.gpio_input_pin_schema, - cv.Required(CONF_VEN_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_EMULATION_MESSAGE): cv.string, - cv.Optional(CONF_TAG_TTL): cv.positive_time_period_milliseconds, } -).extend(cv.COMPONENT_SCHEMA) +) - -@automation.register_action( - "tag.set_emulation_message", - SetEmulationMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_write_message", - SetWriteMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -async def pn7150_set_message_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]) - template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) - cg.add(var.set_message(template_)) - template_ = await cg.templatable( - config[CONF_INCLUDE_ANDROID_APP_RECORD], args, cg.bool_ - ) - cg.add(var.set_include_android_app_record(template_)) - return var - - -@automation.register_action( - "tag.emulation_off", - EmulationOffAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "tag.set_clean_mode", - SetCleanModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_format_mode", - SetFormatModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_read_mode", - SetReadModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_write_mode", - SetWriteModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -async def pn7150_simple_action_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]) - return var - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" - ), - automation.CallbackAutomation( - CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" - ), -) +pn71xx.register_is_writing_condition("pn7150.is_writing", PN7150) async def setup_pn7150(var: MockObj, config: ConfigType) -> None: - await cg.register_component(var, config) - - pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) - - if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): - cg.add(var.set_tag_emulation_message(emulation_message_config)) - cg.add(var.set_tag_emulation_on()) - - if CONF_TAG_TTL in config: - cg.add(var.set_tag_ttl(config[CONF_TAG_TTL])) - - for conf in config.get(CONF_ON_TAG, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontag_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - for conf in config.get(CONF_ON_TAG_REMOVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontagremoved_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - - -@automation.register_condition( - "pn7150.is_writing", - PN7150IsWritingCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7150), - } - ), -) -async def pn7150_is_writing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var + await pn71xx.setup_pn71xx(var, config) diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h deleted file mode 100644 index c3f8d3e5d38..00000000000 --- a/esphome/components/pn7150/automation.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "esphome/components/pn7150/pn7150.h" - -namespace esphome::pn7150 { - -template class PN7150IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - -template class EmulationOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } -}; - -template class EmulationOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } -}; - -template class PollingOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_off(); } -}; - -template class PollingOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_on(); } -}; - -template class SetCleanModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->clean_mode(); } -}; - -template class SetFormatModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->format_mode(); } -}; - -template class SetReadModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->read_mode(); } -}; - -template class SetEmulationMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_emulation_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_write_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->write_mode(); } -}; - -} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150.cpp b/esphome/components/pn7150/pn7150.cpp index 4e679c664a8..25bb7a0c241 100644 --- a/esphome/components/pn7150/pn7150.cpp +++ b/esphome/components/pn7150/pn7150.cpp @@ -1,239 +1,18 @@ -#include "automation.h" #include "pn7150.h" -#include - -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pn7150 { static const char *const TAG = "pn7150"; -void PN7150::setup() { - this->irq_pin_->setup(); - this->ven_pin_->setup(); - - this->nci_fsm_transition_(); // kick off reset & init processes -} - void PN7150::dump_config() { ESP_LOGCONFIG(TAG, "PN7150:"); - LOG_PIN(" IRQ pin: ", this->irq_pin_); - LOG_PIN(" VEN pin: ", this->ven_pin_); + PN71xx::dump_config(); } -void PN7150::loop() { - this->nci_fsm_transition_(); - this->purge_old_tags_(); -} - -void PN7150::set_tag_emulation_message(std::shared_ptr message) { - this->card_emulation_message_ = std::move(message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7150::set_tag_emulation_message(const optional &message, - const optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->card_emulation_message_ = std::move(ndef_message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7150::set_tag_emulation_message(const char *message, const bool include_android_app_record) { - this->set_tag_emulation_message(std::string(message), include_android_app_record); -} - -void PN7150::set_tag_emulation_off() { - if (this->listening_enabled_) { - this->listening_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation disabled"); -} - -void PN7150::set_tag_emulation_on() { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation cannot be enabled"); - return; - } - if (!this->listening_enabled_) { - this->listening_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation enabled"); -} - -void PN7150::set_polling_off() { - if (this->polling_enabled_) { - this->polling_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling disabled"); -} - -void PN7150::set_polling_on() { - if (!this->polling_enabled_) { - this->polling_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling enabled"); -} - -void PN7150::read_mode() { - this->next_task_ = EP_READ; - ESP_LOGD(TAG, "Waiting to read next tag"); -} - -void PN7150::clean_mode() { - this->next_task_ = EP_CLEAN; - ESP_LOGD(TAG, "Waiting to clean next tag"); -} - -void PN7150::format_mode() { - this->next_task_ = EP_FORMAT; - ESP_LOGD(TAG, "Waiting to format next tag"); -} - -void PN7150::write_mode() { - if (this->next_task_message_to_write_ == nullptr) { - ESP_LOGW(TAG, "Message to write must be set before setting write mode"); - return; - } - - this->next_task_ = EP_WRITE; - ESP_LOGD(TAG, "Waiting to write next tag"); -} - -void PN7150::set_tag_write_message(std::shared_ptr message) { - this->next_task_message_to_write_ = std::move(message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -void PN7150::set_tag_write_message(optional message, optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->next_task_message_to_write_ = std::move(ndef_message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -uint8_t PN7150::set_test_mode(const TestMode test_mode, const std::vector &data, - std::vector &result) { - auto test_oid = TEST_PRBS_OID; - - switch (test_mode) { - case TestMode::TEST_PRBS: - // test_oid = TEST_PRBS_OID; - break; - - case TestMode::TEST_ANTENNA: - test_oid = TEST_ANTENNA_OID; - break; - - case TestMode::TEST_GET_REGISTER: - test_oid = TEST_GET_REGISTER_OID; - break; - - case TestMode::TEST_NONE: - default: - ESP_LOGD(TAG, "Exiting test mode"); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_OK; - } - - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::TEST); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, test_oid, data); - - ESP_LOGW(TAG, "Starting test mode, OID 0x%02X", test_oid); - auto status = this->transceive_(tx, rx, NFCC_INIT_TIMEOUT); - - if (status != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to start test mode, OID 0x%02X", test_oid); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - result.clear(); - } else { - result = rx.get_message(); - result.erase(result.begin(), result.begin() + 4); // remove NCI header - if (!result.empty()) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); - } - } - return status; -} - -uint8_t PN7150::reset_core_(const bool reset_config, const bool power) { - if (power) { - this->ven_pin_->digital_write(true); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(false); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(true); - delay(NFCC_INIT_TIMEOUT); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_RESET_OID, - {(uint8_t) reset_config}); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending reset command"); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid reset response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return rx.get_simple_status_response(); - } - // verify reset response +uint8_t PN7150::verify_reset(nfc::NciMessage &rx, const bool reset_config) { + // CORE_RESET_RSP payload: status, NCI version (reported as 1.1, see UM10936 4.1), configuration status if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_RESPONSE)) || (!rx.message_length_is(3)) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1] != 0x11) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 2] != (uint8_t) reset_config)) { @@ -242,33 +21,23 @@ uint8_t PN7150::reset_core_(const bool reset_config, const bool power) { return nfc::STATUS_FAILED; } - ESP_LOGD(TAG, "Configuration %s, NCI version: %s", + ESP_LOGD(TAG, "Configuration %s, NCI version: 0x%02X", rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 2] ? LOG_STR_LITERAL("reset") : LOG_STR_LITERAL("retained"), - rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1] == 0x20 ? LOG_STR_LITERAL("2.0") : LOG_STR_LITERAL("1.0")); + rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1]); return nfc::STATUS_OK; } -uint8_t PN7150::init_core_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_INIT_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending initialise command"); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { +uint8_t PN7150::process_init_response(nfc::NciMessage &rx) { + // NCI 1.0 CORE_INIT_RSP: the manufacturer ID and 4 bytes of manufacturer specific information follow the list of + // supported RF interfaces, whose length is at offset 8 (UM10936, 5.2) + const auto &msg = rx.get_message(); + if (msg.size() < 9u || msg.size() < 20u + msg[8]) { char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid initialise response: %s", nfc::format_bytes_to(buf, rx.get_message())); + ESP_LOGE(TAG, "Initialise response too short: %s", nfc::format_bytes_to(buf, msg)); return nfc::STATUS_FAILED; } - - uint8_t manf_id = rx.get_message()[15 + rx.get_message()[8]]; - uint8_t hw_version = rx.get_message()[16 + rx.get_message()[8]]; - uint8_t rom_code_version = rx.get_message()[17 + rx.get_message()[8]]; - uint8_t flash_major_version = rx.get_message()[18 + rx.get_message()[8]]; - uint8_t flash_minor_version = rx.get_message()[19 + rx.get_message()[8]]; + const uint8_t n = msg[8]; ESP_LOGD(TAG, "PN7150 chip info:\n" @@ -277,886 +46,9 @@ uint8_t PN7150::init_core_() { " ROM code version: 0x%02X\n" " FLASH major version: 0x%02X\n" " FLASH minor version: 0x%02X", - manf_id, hw_version, rom_code_version, flash_major_version, flash_minor_version); + msg[15 + n], msg[16 + n], msg[17 + n], msg[18 + n], msg[19 + n]); return rx.get_simple_status_response(); } -uint8_t PN7150::send_init_config_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, nfc::NCI_CORE_SET_CONFIG_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error enabling proprietary extensions"); - return nfc::STATUS_FAILED; - } - - tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(std::begin(PMU_CFG), std::end(PMU_CFG))); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending PMU config"); - return nfc::STATUS_FAILED; - } - - return this->send_core_config_(); -} - -uint8_t PN7150::send_core_config_() { - const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); - const auto *core_config_end = std::end(CORE_CONFIG_SOLO); - this->core_config_is_solo_ = true; - - if (this->listening_enabled_ && this->polling_enabled_) { - core_config_begin = std::begin(CORE_CONFIG_RW_CE); - core_config_end = std::end(CORE_CONFIG_RW_CE); - this->core_config_is_solo_ = false; - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(core_config_begin, core_config_end)); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error sending core config"); - return nfc::STATUS_FAILED; - } - - return nfc::STATUS_OK; -} - -uint8_t PN7150::refresh_core_config_() { - bool core_config_should_be_solo = !(this->listening_enabled_ && this->polling_enabled_); - - if (this->nci_state_ == NCIState::RFST_DISCOVERY) { - if (this->stop_discovery_() != nfc::STATUS_OK) { - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_FAILED; - } - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - - if (this->core_config_is_solo_ != core_config_should_be_solo) { - if (this->send_core_config_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to refresh core config"); - return nfc::STATUS_FAILED; - } - } - this->config_refresh_pending_ = false; - return nfc::STATUS_OK; -} - -uint8_t PN7150::set_discover_map_() { - std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; - discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending discover map poll config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::set_listen_mode_routing_() { - nfc::NciMessage rx; - nfc::NciMessage tx( - nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, - std::vector(std::begin(RF_LISTEN_MODE_ROUTING_CONFIG), std::end(RF_LISTEN_MODE_ROUTING_CONFIG))); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error setting listen mode routing config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::start_discovery_() { - const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; - uint8_t length = sizeof(RF_DISCOVERY_CONFIG); - - if (!this->listening_enabled_) { - length = sizeof(RF_DISCOVERY_POLL_CONFIG); - rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; - } else if (!this->polling_enabled_) { - length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); - rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; - } - - std::vector discover_config = std::vector((length * 2) + 1); - - discover_config[0] = length; - for (uint8_t i = 0; i < length; i++) { - discover_config[(i * 2) + 1] = rf_discovery_config[i]; - discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - switch (rx.get_simple_status_response()) { - // in any of these cases, we are either already in or will remain in discovery, which satisfies the function call - case nfc::STATUS_OK: - case nfc::DISCOVERY_ALREADY_STARTED: - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - case nfc::DISCOVERY_TEAR_DOWN: - return nfc::STATUS_OK; - - default: - ESP_LOGE(TAG, "Error starting discovery"); - return nfc::STATUS_FAILED; - } - } - - return nfc::STATUS_OK; -} - -uint8_t PN7150::stop_discovery_() { return this->deactivate_(nfc::DEACTIVATION_TYPE_IDLE, NFCC_TAG_WRITE_TIMEOUT); } - -uint8_t PN7150::deactivate_(const uint8_t type, const uint16_t timeout) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {type}); - - auto status = this->transceive_(tx, rx, timeout); - // if (status != nfc::STATUS_OK) { - // ESP_LOGE(TAG, "Error sending deactivate type %u", type); - // return nfc::STATUS_FAILED; - // } - return status; -} - -void PN7150::select_endpoint_() { - if (this->discovered_endpoint_.empty()) { - ESP_LOGW(TAG, "No cached tags to select"); - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - return; - } - std::vector endpoint_data = {this->discovered_endpoint_[0].id, this->discovered_endpoint_[0].protocol, - 0x01}; // that last byte is the interface ID - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - if (!this->discovered_endpoint_[i].trig_called) { - endpoint_data = {this->discovered_endpoint_[i].id, this->discovered_endpoint_[i].protocol, - 0x01}; // that last byte is the interface ID - this->selecting_endpoint_ = i; - break; - } - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error selecting endpoint"); - } else { - this->nci_fsm_set_state_(NCIState::EP_SELECTING); - } -} - -uint8_t PN7150::read_endpoint_data_(nfc::NfcTag &tag) { - uint8_t type = nfc::guess_tag_type(tag.get_uid().size()); - - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - ESP_LOGV(TAG, "Reading Mifare classic"); - return this->read_mifare_classic_tag_(tag); - - case nfc::TAG_TYPE_2: - ESP_LOGV(TAG, "Reading Mifare ultralight"); - return this->read_mifare_ultralight_tag_(tag); - - case nfc::TAG_TYPE_UNKNOWN: - default: - ESP_LOGV(TAG, "Cannot determine tag type"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::clean_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_mifare_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for cleaning"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::format_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_ndef_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for formatting"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->write_mifare_classic_tag_(message); - - case nfc::TAG_TYPE_2: - return this->write_mifare_ultralight_tag_(uid, message); - - default: - ESP_LOGE(TAG, "Unsupported tag for writing"); - break; - } - return nfc::STATUS_FAILED; -} - -std::unique_ptr PN7150::build_tag_(const uint8_t mode_tech, const std::vector &data) { - switch (mode_tech) { - case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { - uint8_t uid_length = data[2]; - if (!uid_length) { - ESP_LOGE(TAG, "UID length cannot be zero"); - return nullptr; - } - nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); - const auto *tag_type_str = - nfc::guess_tag_type(uid_length) == nfc::TAG_TYPE_MIFARE_CLASSIC ? nfc::MIFARE_CLASSIC : nfc::NFC_FORUM_TYPE_2; - return make_unique(uid, tag_type_str); - } - } - return nullptr; -} - -optional PN7150::find_tag_uid_(const nfc::NfcTagUid &uid) { - if (!this->discovered_endpoint_.empty()) { - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - auto existing_tag_uid = this->discovered_endpoint_[i].tag->get_uid(); - bool uid_match = (uid.size() == existing_tag_uid.size()); - - if (uid_match) { - for (size_t i = 0; i < uid.size(); i++) { - uid_match &= (uid[i] == existing_tag_uid[i]); - } - if (uid_match) { - return i; - } - } - } - } - return nullopt; -} - -void PN7150::purge_old_tags_() { - for (size_t i = this->discovered_endpoint_.size(); i > 0; i--) { - if (millis() - this->discovered_endpoint_[i - 1].last_seen > this->tag_ttl_) { - this->erase_tag_(i - 1); - } - } -} - -void PN7150::erase_tag_(const uint8_t tag_index) { - if (tag_index < this->discovered_endpoint_.size()) { - for (auto *trigger : this->triggers_ontagremoved_) { - trigger->process(this->discovered_endpoint_[tag_index].tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_off(*this->discovered_endpoint_[tag_index].tag); - } - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); - this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); - } -} - -void PN7150::nci_fsm_transition_() { - switch (this->nci_state_) { - case NCIState::NFCC_RESET: - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - [[fallthrough]]; - - case NCIState::NFCC_INIT: - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_CONFIG); - } - [[fallthrough]]; - - case NCIState::NFCC_CONFIG: - if (this->send_init_config_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to send initial config"); - this->nci_fsm_set_error_state_(NCIState::NFCC_CONFIG); - return; - } else { - this->config_refresh_pending_ = false; - this->nci_fsm_set_state_(NCIState::NFCC_SET_DISCOVER_MAP); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_DISCOVER_MAP: - if (this->set_discover_map_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set discover map"); - this->nci_fsm_set_error_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_LISTEN_MODE_ROUTING: - if (this->set_listen_mode_routing_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set listen mode routing"); - this->nci_fsm_set_error_state_(NCIState::RFST_IDLE); - return; - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - [[fallthrough]]; - - case NCIState::RFST_IDLE: - if (this->nci_state_error_ == NCIState::RFST_DISCOVERY) { - this->stop_discovery_(); - } - - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - - if (!this->listening_enabled_ && !this->polling_enabled_) { - return; - } - - if (this->start_discovery_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to start discovery"); - this->nci_fsm_set_error_state_(NCIState::RFST_DISCOVERY); - } else { - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - } - return; - - case NCIState::RFST_W4_HOST_SELECT: - select_endpoint_(); - [[fallthrough]]; - - // All cases below are waiting for NOTIFICATION messages - case NCIState::RFST_DISCOVERY: - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - [[fallthrough]]; - - case NCIState::RFST_LISTEN_ACTIVE: - case NCIState::RFST_LISTEN_SLEEP: - case NCIState::RFST_POLL_ACTIVE: - case NCIState::EP_SELECTING: - case NCIState::EP_DEACTIVATING: - if (this->irq_pin_->digital_read()) { - this->process_message_(); - } - break; - - case NCIState::TEST: - case NCIState::FAILED: - case NCIState::NONE: - default: - return; - } -} - -void PN7150::nci_fsm_set_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_state_(%u)", (uint8_t) new_state); - this->nci_state_ = new_state; - this->nci_state_error_ = NCIState::NONE; - this->error_count_ = 0; - this->last_nci_state_change_ = millis(); -} - -bool PN7150::nci_fsm_set_error_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_error_state_(%u); error_count_ = %u", (uint8_t) new_state, this->error_count_); - this->nci_state_error_ = new_state; - if (this->error_count_++ > NFCC_MAX_ERROR_COUNT) { - if ((this->nci_state_error_ == NCIState::NFCC_RESET) || (this->nci_state_error_ == NCIState::NFCC_INIT) || - (this->nci_state_error_ == NCIState::NFCC_CONFIG)) { - ESP_LOGE(TAG, "Too many initialization failures -- check device connections"); - this->mark_failed(); - this->nci_fsm_set_state_(NCIState::FAILED); - } else { - ESP_LOGW(TAG, "Too many errors transitioning to state %u; resetting NFCC", (uint8_t) this->nci_state_error_); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - } - } - return this->error_count_ > NFCC_MAX_ERROR_COUNT; -} - -void PN7150::process_message_() { - nfc::NciMessage rx; - if (this->read_nfcc(rx, NFCC_DEFAULT_TIMEOUT) != nfc::STATUS_OK) { - return; // No data - } - - switch (rx.get_message_type()) { - case nfc::NCI_PKT_MT_CTRL_NOTIFICATION: - if (rx.get_gid() == nfc::RF_GID) { - switch (rx.get_oid()) { - case nfc::RF_INTF_ACTIVATED_OID: - ESP_LOGVV(TAG, "RF_INTF_ACTIVATED_OID"); - this->process_rf_intf_activated_oid_(rx); - return; - - case nfc::RF_DISCOVER_OID: - ESP_LOGVV(TAG, "RF_DISCOVER_OID"); - this->process_rf_discover_oid_(rx); - return; - - case nfc::RF_DEACTIVATE_OID: - ESP_LOGVV(TAG, "RF_DEACTIVATE_OID: type: 0x%02X, reason: 0x%02X", rx.get_message()[3], rx.get_message()[4]); - this->process_rf_deactivate_oid_(rx); - return; - - default: - ESP_LOGV(TAG, "Unimplemented RF OID received: 0x%02X", rx.get_oid()); - } - } else if (rx.get_gid() == nfc::NCI_CORE_GID) { - switch (rx.get_oid()) { - case nfc::NCI_CORE_GENERIC_ERROR_OID: - ESP_LOGV(TAG, "NCI_CORE_GENERIC_ERROR_OID:"); - switch (rx.get_simple_status_response()) { - case nfc::DISCOVERY_ALREADY_STARTED: - ESP_LOGV(TAG, " DISCOVERY_ALREADY_STARTED"); - break; - - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - // Tag removed too soon - ESP_LOGV(TAG, " DISCOVERY_TARGET_ACTIVATION_FAILED"); - if (this->nci_state_ == NCIState::EP_SELECTING) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - if (!this->discovered_endpoint_.empty()) { - this->erase_tag_(this->selecting_endpoint_); - } - } else { - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - case nfc::DISCOVERY_TEAR_DOWN: - ESP_LOGV(TAG, " DISCOVERY_TEAR_DOWN"); - break; - - default: - ESP_LOGW(TAG, "Unknown error: 0x%02X", rx.get_simple_status_response()); - break; - } - break; - - default: - ESP_LOGV(TAG, "Unimplemented NCI Core OID received: 0x%02X", rx.get_oid()); - } - } else { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented notification: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - break; - - case nfc::NCI_PKT_MT_CTRL_RESPONSE: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented GID: 0x%02X OID: 0x%02X Full response: %s", rx.get_gid(), rx.get_oid(), - nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_CTRL_COMMAND: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented command: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_DATA: - this->process_data_message_(rx); - break; - - default: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented message type: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - } -} - -void PN7150::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoint was activated - uint8_t discovery_id = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_DISCOVERY_ID); - uint8_t interface = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_INTERFACE); - uint8_t protocol = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_PROTOCOL); - uint8_t mode_tech = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MODE_TECH); - uint8_t max_size = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MAX_SIZE); - - ESP_LOGVV(TAG, "Endpoint activated -- interface: 0x%02X, protocol: 0x%02X, mode&tech: 0x%02X, max payload: %u", - interface, protocol, mode_tech, max_size); - - if (mode_tech & nfc::MODE_LISTEN_MASK) { - ESP_LOGVV(TAG, "Tag activated in listen mode"); - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_ACTIVE); - return; - } - - this->nci_fsm_set_state_(NCIState::RFST_POLL_ACTIVE); - auto incoming_tag = - this->build_tag_(mode_tech, std::vector(rx.get_message().begin() + 10, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = discovery_id; - this->discovered_endpoint_[tag_loc.value()].protocol = protocol; - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag cache updated"); - } else { - this->discovered_endpoint_.emplace_back( - DiscoveredEndpoint{discovery_id, protocol, millis(), std::move(incoming_tag), false}); - tag_loc = this->discovered_endpoint_.size() - 1; - ESP_LOGVV(TAG, "Tag added to cache"); - } - - auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; - - switch (this->next_task_) { - case EP_CLEAN: - ESP_LOGD(TAG, " Tag cleaning"); - if (this->clean_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag cleaning incomplete"); - } - ESP_LOGD(TAG, " Tag cleaned!"); - break; - - case EP_FORMAT: - ESP_LOGD(TAG, " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error formatting tag as NDEF"); - } - ESP_LOGD(TAG, " Tag formatted!"); - break; - - case EP_WRITE: - if (this->next_task_message_to_write_ != nullptr) { - ESP_LOGD(TAG, " Tag writing\n" - " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag could not be formatted for writing"); - } else { - ESP_LOGD(TAG, " Writing NDEF data"); - if (this->write_endpoint_(working_endpoint.tag->get_uid(), this->next_task_message_to_write_) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, " Failed to write message to tag"); - } - ESP_LOGD(TAG, " Finished writing NDEF data"); - this->next_task_message_to_write_ = nullptr; - this->on_finished_write_callback_.call(); - } - } - break; - - case EP_READ: - default: - if (!working_endpoint.trig_called) { - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Read tag type %s with UID %s", working_endpoint.tag->get_tag_type().c_str(), - nfc::format_uid_to(uid_buf, working_endpoint.tag->get_uid())); - if (this->read_endpoint_data_(*working_endpoint.tag) != nfc::STATUS_OK) { - ESP_LOGW(TAG, " Unable to read NDEF record(s)"); - } else if (working_endpoint.tag->has_ndef_message()) { - const auto message = working_endpoint.tag->get_ndef_message(); - const auto records = message->get_records(); - ESP_LOGD(TAG, " NDEF record(s):"); - for (const auto &record : records) { - ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); - } - } else { - ESP_LOGW(TAG, " No NDEF records found"); - } - for (auto *trigger : this->triggers_ontag_) { - trigger->process(working_endpoint.tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_on(*working_endpoint.tag); - } - working_endpoint.trig_called = true; - break; - } - } - if (working_endpoint.tag->get_tag_type() == nfc::MIFARE_CLASSIC) { - this->halt_mifare_classic_tag_(); - } - } - if (this->next_task_ != EP_READ) { - this->read_mode(); - } - - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); -} - -void PN7150::process_rf_discover_oid_(nfc::NciMessage &rx) { - auto incoming_tag = this->build_tag_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), - std::vector(rx.get_message().begin() + 7, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag!"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); - this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag found & updated"); - } else { - this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), - rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - millis(), std::move(incoming_tag), false}); - ESP_LOGVV(TAG, "Tag saved"); - } - } - - if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - ESP_LOGVV(TAG, "Discovered %u endpoints", this->discovered_endpoint_.size()); - } -} - -void PN7150::process_rf_deactivate_oid_(nfc::NciMessage &rx) { - this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; - - switch (rx.get_simple_status_response()) { - case nfc::DEACTIVATION_TYPE_DISCOVERY: - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - break; - - case nfc::DEACTIVATION_TYPE_IDLE: - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - break; - - case nfc::DEACTIVATION_TYPE_SLEEP: - case nfc::DEACTIVATION_TYPE_SLEEP_AF: - if (this->nci_state_ == NCIState::RFST_LISTEN_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_SLEEP); - } else if (this->nci_state_ == NCIState::RFST_POLL_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - default: - break; - } -} - -void PN7150::process_data_message_(nfc::NciMessage &rx) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - - std::vector ndef_response; - this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); - - uint16_t ndef_response_size = ndef_response.size(); - if (!ndef_response_size) { - return; // no message returned, we cannot respond - } - - std::vector tx_msg = {nfc::NCI_PKT_MT_DATA, uint8_t((ndef_response_size & 0xFF00) >> 8), - uint8_t(ndef_response_size & 0x00FF)}; - tx_msg.insert(tx_msg.end(), ndef_response.begin(), ndef_response.end()); - nfc::NciMessage tx(tx_msg); - ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending reply for card emulation failed"); - } -} - -void PN7150::card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response) { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); - ndef_response.clear(); - return; - } - - if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_APP_SELECT))) { - // CARD_EMU_T4T_APP_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_APP_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_APP_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_CC_SELECT))) { - // CARD_EMU_T4T_CC_SELECT - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_APP_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_CC_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_CC_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_NDEF_SELECT))) { - // CARD_EMU_T4T_NDEF_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_READ), - std::begin(CARD_EMU_T4T_READ))) { - // CARD_EMU_T4T_READ - if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - if (length <= (sizeof(CARD_EMU_T4T_CC) + offset + 2)) { - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_CC) + offset, - std::begin(CARD_EMU_T4T_CC) + offset + length); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED"); - auto ndef_message = this->card_emulation_message_->encode(); - uint16_t ndef_msg_size = ndef_message.size(); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - char ndef_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Encoded NDEF message: %s", nfc::format_bytes_to(ndef_buf, ndef_message)); - - if (length <= (ndef_msg_size + offset + 2)) { - if (offset == 0) { - ndef_response.resize(2); - ndef_response[0] = (ndef_msg_size & 0xFF00) >> 8; - ndef_response[1] = (ndef_msg_size & 0x00FF); - if (length > 2) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 2); - } - } else if (offset == 1) { - ndef_response.resize(1); - ndef_response[0] = (ndef_msg_size & 0x00FF); - if (length > 1) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 1); - } - } else { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length); - } - - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - - if ((offset + length) >= (ndef_msg_size + 2)) { - ESP_LOGD(TAG, "NDEF message sent"); - this->on_emulated_tag_scan_callback_.call(); - } - } - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_WRITE), - std::begin(CARD_EMU_T4T_WRITE))) { - // CARD_EMU_T4T_WRITE - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - std::vector ndef_msg_written; - - ndef_msg_written.insert(ndef_msg_written.end(), response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5 + length); - char ndef_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(ndef_buf, ndef_msg_written)); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } -} - -uint8_t PN7150::transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, const uint16_t timeout, - const bool expect_notification) { - uint8_t retries = NFCC_MAX_COMM_FAILS; - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - - while (retries) { - // first, send the message we need to send - if (this->write_nfcc(tx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending message"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Wrote: %s", nfc::format_bytes_to(buf, tx.get_message())); - // next, the NFCC should send back a response - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error receiving message"); - if (!retries--) { - ESP_LOGE(TAG, " ...giving up"); - return nfc::STATUS_FAILED; - } - } else { - break; - } - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - // validate the response based on the message type that was sent (command vs. data) - if (!tx.message_type_is(nfc::NCI_PKT_MT_DATA)) { - // for commands, the GID and OID should match and the status should be OK - if ((rx.get_gid() != tx.get_gid()) || (rx.get_oid()) != tx.get_oid()) { - ESP_LOGE(TAG, "Incorrect response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - ESP_LOGE(TAG, "Error in response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - return rx.get_simple_status_response(); - } else { - // when requesting data from the endpoint, the first response is from the NFCC; we must validate this, first - if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.gid_is(nfc::NCI_CORE_GID)) || - (!rx.oid_is(nfc::NCI_CORE_CONN_CREDITS_OID)) || (!rx.message_length_is(3))) { - ESP_LOGE(TAG, "Incorrect response to data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (expect_notification) { - // if the NFCC said "OK", there will be additional data to read; this comes back in a notification message - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error receiving data from endpoint"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - - return nfc::STATUS_OK; - } -} - -uint8_t PN7150::wait_for_irq_(uint16_t timeout, bool pin_state) { - auto start_time = millis(); - - while (millis() - start_time < timeout) { - if (this->irq_pin_->digital_read() == pin_state) { - return nfc::STATUS_OK; - } - } - ESP_LOGW(TAG, "Timed out waiting for IRQ state"); - return nfc::STATUS_FAILED; -} - } // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150.h b/esphome/components/pn7150/pn7150.h index fa38c5c3134..7740954050b 100644 --- a/esphome/components/pn7150/pn7150.h +++ b/esphome/components/pn7150/pn7150.h @@ -1,59 +1,9 @@ #pragma once -#include "esphome/components/nfc/automation.h" -#include "esphome/components/nfc/nci_core.h" -#include "esphome/components/nfc/nci_message.h" -#include "esphome/components/nfc/nfc.h" -#include "esphome/components/nfc/nfc_helpers.h" -#include "esphome/core/component.h" -#include "esphome/core/gpio.h" -#include "esphome/core/helpers.h" - -#include +#include "esphome/components/pn71xx/pn71xx.h" namespace esphome::pn7150 { -static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 10; -static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; -static constexpr uint16_t NFCC_TAG_WRITE_TIMEOUT = 15; - -static constexpr uint8_t NFCC_MAX_COMM_FAILS = 3; -static constexpr uint8_t NFCC_MAX_ERROR_COUNT = 10; - -static constexpr uint8_t XCHG_DATA_OID = 0x10; -static constexpr uint8_t MF_SECTORSEL_OID = 0x32; -static constexpr uint8_t MFC_AUTHENTICATE_OID = 0x40; -static constexpr uint8_t TEST_PRBS_OID = 0x30; -static constexpr uint8_t TEST_ANTENNA_OID = 0x3D; -static constexpr uint8_t TEST_GET_REGISTER_OID = 0x33; - -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_A = 0x00; // key select A -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_B = 0x80; // key select B -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_EMBED_KEY = 0x10; - -static constexpr uint8_t CARD_EMU_T4T_APP_SELECT[] = {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, - 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC[] = {0x00, 0x0F, 0x20, 0x00, 0xFF, 0x00, 0xFF, 0x04, - 0x06, 0xE1, 0x04, 0x00, 0xFF, 0x00, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}; -static constexpr uint8_t CARD_EMU_T4T_NDEF_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}; -static constexpr uint8_t CARD_EMU_T4T_READ[] = {0x00, 0xB0}; -static constexpr uint8_t CARD_EMU_T4T_WRITE[] = {0x00, 0xD6}; -static constexpr uint8_t CARD_EMU_T4T_OK[] = {0x90, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_NOK[] = {0x6A, 0x82}; - -static constexpr uint8_t CORE_CONFIG_SOLO[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0x01, // TOTAL_DURATION (low)... - 0x00}; // TOTAL_DURATION (high): 1 ms - -static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0xF8, // TOTAL_DURATION (low)... - 0x02}; // TOTAL_DURATION (high): 760 ms - static constexpr uint8_t PMU_CFG[] = { 0x01, // Number of parameters 0xA0, 0x0E, // ext. tag @@ -63,34 +13,6 @@ static constexpr uint8_t PMU_CFG[] = { 0x01, // RFU; must be 0x00 for CFG1 and 0x01 for CFG2 }; -static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes - nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T3T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_ISODEP, nfc::RF_DISCOVER_MAP_MODE_POLL | nfc::RF_DISCOVER_MAP_MODE_LISTEN, - nfc::INTF_ISODEP, // poll & listen mode - nfc::PROT_MIFARE, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_TAGCMD}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_LISTEN_CONFIG[] = { - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - -static constexpr uint8_t RF_DISCOVERY_POLL_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF, // poll mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (another message is coming) 1, // number of table entries 0x01, // type = protocol-based @@ -99,196 +21,15 @@ static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (an 0x01, // power state nfc::PROT_ISODEP}; // protocol -enum class CardEmulationState : uint8_t { - CARD_EMU_IDLE, - CARD_EMU_NDEF_APP_SELECTED, - CARD_EMU_CC_SELECTED, - CARD_EMU_NDEF_SELECTED, - CARD_EMU_DESFIRE_PROD, -}; - -enum class NCIState : uint8_t { - NONE = 0x00, - NFCC_RESET, - NFCC_INIT, - NFCC_CONFIG, - NFCC_SET_DISCOVER_MAP, - NFCC_SET_LISTEN_MODE_ROUTING, - RFST_IDLE, - RFST_DISCOVERY, - RFST_W4_ALL_DISCOVERIES, - RFST_W4_HOST_SELECT, - RFST_LISTEN_ACTIVE, - RFST_LISTEN_SLEEP, - RFST_POLL_ACTIVE, - EP_DEACTIVATING, - EP_SELECTING, - TEST = 0xFE, - FAILED = 0xFF, -}; - -enum class TestMode : uint8_t { - TEST_NONE = 0x00, - TEST_PRBS, - TEST_ANTENNA, - TEST_GET_REGISTER, -}; - -struct DiscoveredEndpoint { - uint8_t id; - uint8_t protocol; - uint32_t last_seen; - std::unique_ptr tag; - bool trig_called; -}; - -class PN7150 : public nfc::Nfcc, public Component { +class PN7150 : public pn71xx::PN71xx { public: - void setup() override; void dump_config() override; - void loop() override; - - void set_irq_pin(GPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } - void set_ven_pin(GPIOPin *ven_pin) { this->ven_pin_ = ven_pin; } - - void set_tag_ttl(uint32_t ttl) { this->tag_ttl_ = ttl; } - void set_tag_emulation_message(std::shared_ptr message); - void set_tag_emulation_message(const optional &message, optional include_android_app_record); - void set_tag_emulation_message(const char *message, bool include_android_app_record = true); - void set_tag_emulation_off(); - void set_tag_emulation_on(); - bool tag_emulation_enabled() { return this->listening_enabled_; } - - void set_polling_off(); - void set_polling_on(); - bool polling_enabled() { return this->polling_enabled_; } - - void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } - void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } - - template void add_on_emulated_tag_scan_callback(F &&callback) { - this->on_emulated_tag_scan_callback_.add(std::forward(callback)); - } - - template void add_on_finished_write_callback(F &&callback) { - this->on_finished_write_callback_.add(std::forward(callback)); - } - - bool is_writing() { return this->next_task_ != EP_READ; }; - - void read_mode(); - void clean_mode(); - void format_mode(); - void write_mode(); - void set_tag_write_message(std::shared_ptr message); - void set_tag_write_message(optional message, optional include_android_app_record); - - uint8_t set_test_mode(TestMode test_mode, const std::vector &data, std::vector &result); protected: - uint8_t reset_core_(bool reset_config, bool power); - uint8_t init_core_(); - uint8_t send_init_config_(); - uint8_t send_core_config_(); - uint8_t refresh_core_config_(); - - uint8_t set_discover_map_(); - - uint8_t set_listen_mode_routing_(); - - uint8_t start_discovery_(); - uint8_t stop_discovery_(); - uint8_t deactivate_(uint8_t type, uint16_t timeout = NFCC_DEFAULT_TIMEOUT); - - void select_endpoint_(); - - uint8_t read_endpoint_data_(nfc::NfcTag &tag); - uint8_t clean_endpoint_(nfc::NfcTagUid &uid); - uint8_t format_endpoint_(nfc::NfcTagUid &uid); - uint8_t write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message); - - std::unique_ptr build_tag_(uint8_t mode_tech, const std::vector &data); - optional find_tag_uid_(const nfc::NfcTagUid &uid); - void purge_old_tags_(); - void erase_tag_(uint8_t tag_index); - - /// advance controller state as required - void nci_fsm_transition_(); - /// set new controller state - void nci_fsm_set_state_(NCIState new_state); - /// setting controller to this state caused an error; returns true if too many errors/failures - bool nci_fsm_set_error_state_(NCIState new_state); - /// parse & process incoming messages from the NFCC - void process_message_(); - void process_rf_intf_activated_oid_(nfc::NciMessage &rx); - void process_rf_discover_oid_(nfc::NciMessage &rx); - void process_rf_deactivate_oid_(nfc::NciMessage &rx); - void process_data_message_(nfc::NciMessage &rx); - - void card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response); - - uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, - bool expect_notification = true); - virtual uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) = 0; - virtual uint8_t write_nfcc(nfc::NciMessage &tx) = 0; - - uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); - - uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); - uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); - uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); - uint8_t sect_to_auth_(uint8_t block_num); - uint8_t format_mifare_classic_mifare_(); - uint8_t format_mifare_classic_ndef_(); - uint8_t write_mifare_classic_tag_(const std::shared_ptr &message); - uint8_t halt_mifare_classic_tag_(); - - uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); - uint16_t read_mifare_ultralight_capacity_(); - uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, - uint8_t &message_start_index); - uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); - uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); - uint8_t clean_mifare_ultralight_(); - - enum NfcTask : uint8_t { - EP_READ = 0, - EP_CLEAN, - EP_FORMAT, - EP_WRITE, - } next_task_{EP_READ}; - - bool config_refresh_pending_{false}; - bool core_config_is_solo_{false}; - bool listening_enabled_{false}; - bool polling_enabled_{true}; - - uint8_t error_count_{0}; - uint8_t fail_count_{0}; - uint32_t last_nci_state_change_{0}; - uint8_t selecting_endpoint_{0}; - uint32_t tag_ttl_{250}; - - GPIOPin *irq_pin_{nullptr}; - GPIOPin *ven_pin_{nullptr}; - - CallbackManager on_emulated_tag_scan_callback_; - CallbackManager on_finished_write_callback_; - - std::vector discovered_endpoint_; - - CardEmulationState ce_state_{CardEmulationState::CARD_EMU_IDLE}; - NCIState nci_state_{NCIState::NFCC_RESET}; - NCIState nci_state_error_{NCIState::NONE}; - - std::shared_ptr card_emulation_message_; - std::shared_ptr next_task_message_to_write_; - - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; + uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override; + uint8_t process_init_response(nfc::NciMessage &rx) override; + std::span pmu_config() const override { return PMU_CFG; } + std::span listen_mode_routing_config() const override { return RF_LISTEN_MODE_ROUTING_CONFIG; } }; } // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150_mifare_ultralight.cpp b/esphome/components/pn7150/pn7150_mifare_ultralight.cpp deleted file mode 100644 index ef594144d92..00000000000 --- a/esphome/components/pn7150/pn7150_mifare_ultralight.cpp +++ /dev/null @@ -1,185 +0,0 @@ -#include -#include -#include - -#include "pn7150.h" -#include "esphome/core/log.h" - -namespace esphome::pn7150 { - -static const char *const TAG = "pn7150.mifare_ultralight"; - -uint8_t PN7150::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { - std::vector data; - // pages 3 to 6 contain various info we are interested in -- do one read to grab it all - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, - data) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - - if (!this->is_mifare_ultralight_formatted_(data)) { - ESP_LOGW(TAG, "Not NDEF formatted"); - return nfc::STATUS_FAILED; - } - - uint8_t message_length; - uint8_t message_start_index; - if (this->find_mifare_ultralight_ndef_(data, message_length, message_start_index) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Couldn't find NDEF message"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "NDEF message length: %u, start: %u", message_length, message_start_index); - - if (message_length == 0) { - return nfc::STATUS_FAILED; - } - // we already read pages 3-6 earlier -- pick up where we left off so we're not re-reading pages - const uint8_t read_length = message_length + message_start_index > 12 ? message_length + message_start_index - 12 : 0; - if (read_length) { - if (read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, read_length, data) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error reading tag data"); - return nfc::STATUS_FAILED; - } - } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); - - tag.set_ndef_message(make_unique(data)); - - return nfc::STATUS_OK; -} - -uint8_t PN7150::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { - const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); - - for (size_t i = 0; i * read_increment < num_bytes; i++) { - tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; - do { // loop because sometimes we struggle here...???... - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error reading tag data"); - return nfc::STATUS_FAILED; - } - } while (rx.get_payload_size() < read_increment); - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 - : rx.get_message().end() - (bytes_offset - num_bytes + 1); - - if ((pages_in_end_itr > rx.get_message().begin()) && (pages_in_end_itr < rx.get_message().end())) { - data.insert(data.end(), rx.get_message().begin() + nfc::NCI_PKT_HEADER_SIZE, pages_in_end_itr); - } - } - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Data read: %s", nfc::format_bytes_to(buf, data)); - - return nfc::STATUS_OK; -} - -bool PN7150::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { - const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector - - return (page_3_to_6.size() > p4_offset + 3) && - ((page_3_to_6[p4_offset + 0] != 0xFF) || (page_3_to_6[p4_offset + 1] != 0xFF) || - (page_3_to_6[p4_offset + 2] != 0xFF) || (page_3_to_6[p4_offset + 3] != 0xFF)); -} - -uint16_t PN7150::read_mifare_ultralight_capacity_() { - std::vector data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { - ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); - return data[2] * 8U; - } - return 0; -} - -uint8_t PN7150::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, - uint8_t &message_start_index) { - const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector - - if (!(page_3_to_6.size() > p4_offset + 6)) { - return nfc::STATUS_FAILED; - } - - if (page_3_to_6[p4_offset + 0] == 0x03) { - message_length = page_3_to_6[p4_offset + 1]; - message_start_index = 2; - return nfc::STATUS_OK; - } else if (page_3_to_6[p4_offset + 5] == 0x03) { - message_length = page_3_to_6[p4_offset + 6]; - message_start_index = 7; - return nfc::STATUS_OK; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { - uint32_t capacity = this->read_mifare_ultralight_capacity_(); - - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); - - if (buffer_length > capacity) { - ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); - return nfc::STATUS_FAILED; - } - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); - - uint32_t index = 0; - uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; - - while (index < buffer_length) { - if (this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != - nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - index += nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - current_page++; - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::clean_mifare_ultralight_() { - uint32_t capacity = this->read_mifare_ultralight_capacity_(); - uint8_t pages = (capacity / nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) + nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; - - static constexpr std::array BLANK_DATA = {0x00, 0x00, 0x00, 0x00}; - - for (int i = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; i < pages; i++) { - if (this->write_mifare_ultralight_page_(i, BLANK_DATA.data(), BLANK_DATA.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; - payload.insert(payload.end(), write_data, write_data + len); - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, payload); - - if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error writing page %u", page_num); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index a61bd27c64e..58d9daa311e 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -12,28 +12,26 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); if (!this->read_bytes_raw(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE)) { return nfc::STATUS_FAILED; } - uint8_t length = rx.get_payload_size(); - if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); - if (!this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { - return nfc::STATUS_FAILED; - } - } - // semaphore to ensure transaction is complete before returning - if (this->wait_for_irq_(pn7150::NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "read_nfcc_() post-read timeout waiting for IRQ line to clear"); + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); + if (length > 0 && !this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { return nfc::STATUS_FAILED; } + // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short + // low pulse may be missed; that means more data is waiting, not that this read failed (UM10936, 3.4). + if (this->wait_for_irq_(pn71xx::NFCC_IRQ_CLEAR_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGVV(TAG, "IRQ still active after read; another message is pending"); + } return nfc::STATUS_OK; } uint8_t PN7150I2C::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 7f9f9172a15..4cde36e9e73 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -1,252 +1,37 @@ -from esphome import automation, pins -from esphome.automation import maybe_simple_id +from esphome import pins import esphome.codegen as cg -from esphome.components import nfc +from esphome.components import pn71xx import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_IRQ_PIN, - CONF_MESSAGE, - CONF_ON_FINISHED_WRITE, - CONF_ON_TAG, - CONF_ON_TAG_REMOVED, - CONF_TRIGGER_ID, -) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType -AUTO_LOAD = ["binary_sensor", "nfc"] +AUTO_LOAD = ["pn71xx"] CODEOWNERS = ["@kbx81", "@jesserockz"] CONF_DWL_REQ_PIN = "dwl_req_pin" -CONF_EMULATION_MESSAGE = "emulation_message" -CONF_EMULATION_OFF = "emulation_off" -CONF_EMULATION_ON = "emulation_on" -CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" -CONF_ON_EMULATED_TAG_SCAN = "on_emulated_tag_scan" -CONF_PN7160_ID = "pn7160_id" -CONF_POLLING_OFF = "polling_off" -CONF_POLLING_ON = "polling_on" -CONF_SET_CLEAN_MODE = "set_clean_mode" -CONF_SET_EMULATION_MESSAGE = "set_emulation_message" -CONF_SET_FORMAT_MODE = "set_format_mode" -CONF_SET_READ_MODE = "set_read_mode" -CONF_SET_WRITE_MESSAGE = "set_write_message" -CONF_SET_WRITE_MODE = "set_write_mode" -CONF_TAG_TTL = "tag_ttl" -CONF_VEN_PIN = "ven_pin" CONF_WKUP_REQ_PIN = "wkup_req_pin" pn7160_ns = cg.esphome_ns.namespace("pn7160") -PN7160 = pn7160_ns.class_("PN7160", nfc.Nfcc, cg.Component) +PN7160 = pn7160_ns.class_("PN7160", pn71xx.PN71xx) -EmulationOffAction = pn7160_ns.class_("EmulationOffAction", automation.Action) -EmulationOnAction = pn7160_ns.class_("EmulationOnAction", automation.Action) -PollingOffAction = pn7160_ns.class_("PollingOffAction", automation.Action) -PollingOnAction = pn7160_ns.class_("PollingOnAction", automation.Action) -SetCleanModeAction = pn7160_ns.class_("SetCleanModeAction", automation.Action) -SetEmulationMessageAction = pn7160_ns.class_( - "SetEmulationMessageAction", automation.Action -) -SetFormatModeAction = pn7160_ns.class_("SetFormatModeAction", automation.Action) -SetReadModeAction = pn7160_ns.class_("SetReadModeAction", automation.Action) -SetWriteMessageAction = pn7160_ns.class_("SetWriteMessageAction", automation.Action) -SetWriteModeAction = pn7160_ns.class_("SetWriteModeAction", automation.Action) - - -PN7160IsWritingCondition = pn7160_ns.class_( - "PN7160IsWritingCondition", automation.Condition -) - - -IsWritingCondition = nfc.nfc_ns.class_("IsWritingCondition", automation.Condition) - - -SIMPLE_ACTION_SCHEMA = maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(PN7160), - } -) - -SET_MESSAGE_ACTION_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7160), - cv.Required(CONF_MESSAGE): cv.templatable(cv.string), - cv.Optional(CONF_INCLUDE_ANDROID_APP_RECORD, default=True): cv.boolean, - } -) - -PN7160_SCHEMA = cv.Schema( +PN7160_SCHEMA = pn71xx.PN71XX_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(PN7160), - cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), - cv.Optional(CONF_ON_TAG): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), - cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), cv.Optional(CONF_DWL_REQ_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_IRQ_PIN): pins.gpio_input_pin_schema, - cv.Required(CONF_VEN_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_WKUP_REQ_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_EMULATION_MESSAGE): cv.string, - cv.Optional(CONF_TAG_TTL): cv.positive_time_period_milliseconds, } -).extend(cv.COMPONENT_SCHEMA) +) - -@automation.register_action( - "tag.set_emulation_message", - SetEmulationMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_write_message", - SetWriteMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -async def pn7160_set_message_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]) - template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) - cg.add(var.set_message(template_)) - template_ = await cg.templatable( - config[CONF_INCLUDE_ANDROID_APP_RECORD], args, cg.bool_ - ) - cg.add(var.set_include_android_app_record(template_)) - return var - - -@automation.register_action( - "tag.emulation_off", - EmulationOffAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "tag.set_clean_mode", - SetCleanModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_format_mode", - SetFormatModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_read_mode", - SetReadModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_write_mode", - SetWriteModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) -async def pn7160_simple_action_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]) - return var - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" - ), - automation.CallbackAutomation( - CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" - ), -) +pn71xx.register_is_writing_condition("pn7160.is_writing", PN7160) async def setup_pn7160(var: MockObj, config: ConfigType) -> None: - await cg.register_component(var, config) + await pn71xx.setup_pn71xx(var, config) if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): pin = await cg.gpio_pin_expression(dwl_req_pin_config) cg.add(var.set_dwl_req_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) - - if wakeup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): - pin = await cg.gpio_pin_expression(wakeup_req_pin_config) + if wkup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): + pin = await cg.gpio_pin_expression(wkup_req_pin_config) cg.add(var.set_wkup_req_pin(pin)) - - if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): - cg.add(var.set_tag_emulation_message(emulation_message_config)) - cg.add(var.set_tag_emulation_on()) - - if CONF_TAG_TTL in config: - cg.add(var.set_tag_ttl(config[CONF_TAG_TTL])) - - for conf in config.get(CONF_ON_TAG, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontag_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - for conf in config.get(CONF_ON_TAG_REMOVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontagremoved_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - - -@automation.register_condition( - "pn7160.is_writing", - PN7160IsWritingCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7160), - } - ), -) -async def pn7160_is_writing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h deleted file mode 100644 index 9f03a5a3d63..00000000000 --- a/esphome/components/pn7160/automation.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "esphome/components/pn7160/pn7160.h" - -namespace esphome::pn7160 { - -template class PN7160IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - -template class EmulationOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } -}; - -template class EmulationOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } -}; - -template class PollingOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_off(); } -}; - -template class PollingOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_on(); } -}; - -template class SetCleanModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->clean_mode(); } -}; - -template class SetFormatModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->format_mode(); } -}; - -template class SetReadModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->read_mode(); } -}; - -template class SetEmulationMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_emulation_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_write_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->write_mode(); } -}; - -} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160.cpp b/esphome/components/pn7160/pn7160.cpp index f2cbfa6bcf7..185ff0ce097 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -1,10 +1,6 @@ -#include - -#include "automation.h" #include "pn7160.h" #include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pn7160 { @@ -12,250 +8,40 @@ namespace esphome::pn7160 { static const char *const TAG = "pn7160"; void PN7160::setup() { - this->irq_pin_->setup(); - this->ven_pin_->setup(); if (this->dwl_req_pin_ != nullptr) { this->dwl_req_pin_->setup(); } if (this->wkup_req_pin_ != nullptr) { this->wkup_req_pin_->setup(); } - - this->nci_fsm_transition_(); // kick off reset & init processes + PN71xx::setup(); } void PN7160::dump_config() { ESP_LOGCONFIG(TAG, "PN7160:"); + PN71xx::dump_config(); if (this->dwl_req_pin_ != nullptr) { LOG_PIN(" DWL_REQ pin: ", this->dwl_req_pin_); } - LOG_PIN(" IRQ pin: ", this->irq_pin_); - LOG_PIN(" VEN pin: ", this->ven_pin_); if (this->wkup_req_pin_ != nullptr) { LOG_PIN(" WKUP_REQ pin: ", this->wkup_req_pin_); } } -void PN7160::loop() { - this->nci_fsm_transition_(); - this->purge_old_tags_(); -} - -void PN7160::set_tag_emulation_message(std::shared_ptr message) { - this->card_emulation_message_ = std::move(message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7160::set_tag_emulation_message(const optional &message, - const optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->card_emulation_message_ = std::move(ndef_message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7160::set_tag_emulation_message(const char *message, const bool include_android_app_record) { - this->set_tag_emulation_message(std::string(message), include_android_app_record); -} - -void PN7160::set_tag_emulation_off() { - if (this->listening_enabled_) { - this->listening_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation disabled"); -} - -void PN7160::set_tag_emulation_on() { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation cannot be enabled"); - return; - } - if (!this->listening_enabled_) { - this->listening_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation enabled"); -} - -void PN7160::set_polling_off() { - if (this->polling_enabled_) { - this->polling_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling disabled"); -} - -void PN7160::set_polling_on() { - if (!this->polling_enabled_) { - this->polling_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling enabled"); -} - -void PN7160::read_mode() { - this->next_task_ = EP_READ; - ESP_LOGD(TAG, "Waiting to read next tag"); -} - -void PN7160::clean_mode() { - this->next_task_ = EP_CLEAN; - ESP_LOGD(TAG, "Waiting to clean next tag"); -} - -void PN7160::format_mode() { - this->next_task_ = EP_FORMAT; - ESP_LOGD(TAG, "Waiting to format next tag"); -} - -void PN7160::write_mode() { - if (this->next_task_message_to_write_ == nullptr) { - ESP_LOGW(TAG, "Message to write must be set before setting write mode"); - return; - } - - this->next_task_ = EP_WRITE; - ESP_LOGD(TAG, "Waiting to write next tag"); -} - -void PN7160::set_tag_write_message(std::shared_ptr message) { - this->next_task_message_to_write_ = std::move(message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -void PN7160::set_tag_write_message(optional message, optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->next_task_message_to_write_ = std::move(ndef_message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -uint8_t PN7160::set_test_mode(const TestMode test_mode, const std::vector &data, - std::vector &result) { - auto test_oid = TEST_PRBS_OID; - - switch (test_mode) { - case TestMode::TEST_PRBS: - // test_oid = TEST_PRBS_OID; - break; - - case TestMode::TEST_ANTENNA: - test_oid = TEST_ANTENNA_OID; - break; - - case TestMode::TEST_GET_REGISTER: - test_oid = TEST_GET_REGISTER_OID; - break; - - case TestMode::TEST_NONE: - default: - ESP_LOGD(TAG, "Exiting test mode"); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_OK; - } - - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::TEST); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, test_oid, data); - - ESP_LOGW(TAG, "Starting test mode, OID 0x%02X", test_oid); - auto status = this->transceive_(tx, rx, NFCC_INIT_TIMEOUT); - - if (status != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to start test mode, OID 0x%02X", test_oid); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - result.clear(); - } else { - result = rx.get_message(); - result.erase(result.begin(), result.begin() + 4); // remove NCI header - if (!result.empty()) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); - } - } - return status; -} - -uint8_t PN7160::reset_core_(const bool reset_config, const bool power) { +void PN7160::prepare_reset() { + // DWL_REQ must be low when VEN rises, or the chip starts in firmware download mode (UM11495) if (this->dwl_req_pin_ != nullptr) { this->dwl_req_pin_->digital_write(false); - delay(NFCC_DEFAULT_TIMEOUT); + delay(pn71xx::NFCC_RESET_DELAY); } +} - if (power) { - this->ven_pin_->digital_write(true); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(false); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(true); - delay(NFCC_INIT_TIMEOUT); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_RESET_OID, - {(uint8_t) reset_config}); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending reset command"); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid reset response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return rx.get_simple_status_response(); - } - // read reset notification - if (this->read_nfcc(rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { +uint8_t PN7160::verify_reset(nfc::NciMessage &rx, const bool reset_config) { + // PN7160 always sends CORE_RESET_NTF after CORE_RESET_RSP (UM11495, 8.2) + if (this->read_nfcc(rx, pn71xx::NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Reset notification was not received"); return nfc::STATUS_FAILED; } - // verify reset notification if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.message_length_is(9)) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET] != 0x02) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1] != (uint8_t) reset_config)) { @@ -264,925 +50,29 @@ uint8_t PN7160::reset_core_(const bool reset_config, const bool power) { return nfc::STATUS_FAILED; } - ESP_LOGD(TAG, "Configuration %s, NCI version: %s, Manufacturer ID: 0x%02X", - rx.get_message()[4] ? LOG_STR_LITERAL("reset") : LOG_STR_LITERAL("retained"), - rx.get_message()[5] == 0x20 ? LOG_STR_LITERAL("2.0") : LOG_STR_LITERAL("1.0"), rx.get_message()[6]); - rx.get_message().erase(rx.get_message().begin(), rx.get_message().begin() + 8); - char mfr_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Manufacturer info: %s", nfc::format_bytes_to(mfr_buf, rx.get_message())); + // payload: trigger, config status, NCI version, manufacturer ID, info length (4), then the manufacturer specific + // information: hardware, ROM code, FLASH major and FLASH minor versions (UM11495, 8.2 and 8.3) + const auto &ntf = rx.get_message(); + ESP_LOGD(TAG, + "Configuration %s, NCI version: 0x%02X, Manufacturer ID: 0x%02X\n" + " Hardware version: %u\n" + " ROM code version: %u\n" + " FLASH major version: %u\n" + " FLASH minor version: %u", + ntf[4] ? LOG_STR_LITERAL("reset") : LOG_STR_LITERAL("retained"), ntf[5], ntf[6], ntf[8], ntf[9], ntf[10], + ntf[11]); return nfc::STATUS_OK; } -uint8_t PN7160::init_core_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_INIT_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending initialise command"); - return nfc::STATUS_FAILED; +uint8_t PN7160::process_init_response(nfc::NciMessage &rx) { + // the chip's version information is logged from CORE_RESET_NTF in verify_reset() + if (rx.get_message().size() >= 8) { + char feat_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGD(TAG, "NFCC features: %s", nfc::format_bytes_to(feat_buf, rx.get_payload().subspan(1, 4))); } - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid initialise response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - uint8_t hw_version = rx.get_message()[17 + rx.get_message()[8]]; - uint8_t rom_code_version = rx.get_message()[18 + rx.get_message()[8]]; - uint8_t flash_major_version = rx.get_message()[19 + rx.get_message()[8]]; - uint8_t flash_minor_version = rx.get_message()[20 + rx.get_message()[8]]; - std::vector features(rx.get_message().begin() + 4, rx.get_message().begin() + 8); - - char feat_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, - "PN7160 chip info:\n" - " Hardware version: %u\n" - " ROM code version: %u\n" - " FLASH major version: %u\n" - " FLASH minor version: %u\n" - " Features: %s", - hw_version, rom_code_version, flash_major_version, flash_minor_version, - nfc::format_bytes_to(feat_buf, features)); - return rx.get_simple_status_response(); } -uint8_t PN7160::send_init_config_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, nfc::NCI_CORE_SET_CONFIG_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error enabling proprietary extensions"); - return nfc::STATUS_FAILED; - } - - tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(std::begin(PMU_CFG), std::end(PMU_CFG))); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending PMU config"); - return nfc::STATUS_FAILED; - } - - return this->send_core_config_(); -} - -uint8_t PN7160::send_core_config_() { - const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); - const auto *core_config_end = std::end(CORE_CONFIG_SOLO); - this->core_config_is_solo_ = true; - - if (this->listening_enabled_ && this->polling_enabled_) { - core_config_begin = std::begin(CORE_CONFIG_RW_CE); - core_config_end = std::end(CORE_CONFIG_RW_CE); - this->core_config_is_solo_ = false; - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(core_config_begin, core_config_end)); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error sending core config"); - return nfc::STATUS_FAILED; - } - - return nfc::STATUS_OK; -} - -uint8_t PN7160::refresh_core_config_() { - bool core_config_should_be_solo = !(this->listening_enabled_ && this->polling_enabled_); - - if (this->nci_state_ == NCIState::RFST_DISCOVERY) { - if (this->stop_discovery_() != nfc::STATUS_OK) { - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_FAILED; - } - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - - if (this->core_config_is_solo_ != core_config_should_be_solo) { - if (this->send_core_config_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to refresh core config"); - return nfc::STATUS_FAILED; - } - } - this->config_refresh_pending_ = false; - return nfc::STATUS_OK; -} - -uint8_t PN7160::set_discover_map_() { - std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; - discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending discover map poll config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::set_listen_mode_routing_() { - nfc::NciMessage rx; - nfc::NciMessage tx( - nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, - std::vector(std::begin(RF_LISTEN_MODE_ROUTING_CONFIG), std::end(RF_LISTEN_MODE_ROUTING_CONFIG))); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error setting listen mode routing config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::start_discovery_() { - const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; - uint8_t length = sizeof(RF_DISCOVERY_CONFIG); - - if (!this->listening_enabled_) { - length = sizeof(RF_DISCOVERY_POLL_CONFIG); - rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; - } else if (!this->polling_enabled_) { - length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); - rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; - } - - std::vector discover_config = std::vector((length * 2) + 1); - - discover_config[0] = length; - for (uint8_t i = 0; i < length; i++) { - discover_config[(i * 2) + 1] = rf_discovery_config[i]; - discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - switch (rx.get_simple_status_response()) { - // in any of these cases, we are either already in or will remain in discovery, which satisfies the function call - case nfc::STATUS_OK: - case nfc::DISCOVERY_ALREADY_STARTED: - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - case nfc::DISCOVERY_TEAR_DOWN: - return nfc::STATUS_OK; - - default: - ESP_LOGE(TAG, "Error starting discovery"); - return nfc::STATUS_FAILED; - } - } - - return nfc::STATUS_OK; -} - -uint8_t PN7160::stop_discovery_() { return this->deactivate_(nfc::DEACTIVATION_TYPE_IDLE, NFCC_TAG_WRITE_TIMEOUT); } - -uint8_t PN7160::deactivate_(const uint8_t type, const uint16_t timeout) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {type}); - - auto status = this->transceive_(tx, rx, timeout); - // if (status != nfc::STATUS_OK) { - // ESP_LOGE(TAG, "Error sending deactivate type %u", type); - // return nfc::STATUS_FAILED; - // } - return status; -} - -void PN7160::select_endpoint_() { - if (this->discovered_endpoint_.empty()) { - ESP_LOGW(TAG, "No cached tags to select"); - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - return; - } - std::vector endpoint_data = {this->discovered_endpoint_[0].id, this->discovered_endpoint_[0].protocol, - 0x01}; // that last byte is the interface ID - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - if (!this->discovered_endpoint_[i].trig_called) { - endpoint_data = {this->discovered_endpoint_[i].id, this->discovered_endpoint_[i].protocol, - 0x01}; // that last byte is the interface ID - this->selecting_endpoint_ = i; - break; - } - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error selecting endpoint"); - } else { - this->nci_fsm_set_state_(NCIState::EP_SELECTING); - } -} - -uint8_t PN7160::read_endpoint_data_(nfc::NfcTag &tag) { - uint8_t type = nfc::guess_tag_type(tag.get_uid().size()); - - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - ESP_LOGV(TAG, "Reading Mifare classic"); - return this->read_mifare_classic_tag_(tag); - - case nfc::TAG_TYPE_2: - ESP_LOGV(TAG, "Reading Mifare ultralight"); - return this->read_mifare_ultralight_tag_(tag); - - case nfc::TAG_TYPE_UNKNOWN: - default: - ESP_LOGV(TAG, "Cannot determine tag type"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::clean_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_mifare_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for cleaning"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::format_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_ndef_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for formatting"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->write_mifare_classic_tag_(message); - - case nfc::TAG_TYPE_2: - return this->write_mifare_ultralight_tag_(uid, message); - - default: - ESP_LOGE(TAG, "Unsupported tag for writing"); - break; - } - return nfc::STATUS_FAILED; -} - -std::unique_ptr PN7160::build_tag_(const uint8_t mode_tech, const std::vector &data) { - switch (mode_tech) { - case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { - uint8_t uid_length = data[2]; - if (!uid_length) { - ESP_LOGE(TAG, "UID length cannot be zero"); - return nullptr; - } - nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); - const auto *tag_type_str = - nfc::guess_tag_type(uid_length) == nfc::TAG_TYPE_MIFARE_CLASSIC ? nfc::MIFARE_CLASSIC : nfc::NFC_FORUM_TYPE_2; - return make_unique(uid, tag_type_str); - } - } - return nullptr; -} - -optional PN7160::find_tag_uid_(const nfc::NfcTagUid &uid) { - if (!this->discovered_endpoint_.empty()) { - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - auto existing_tag_uid = this->discovered_endpoint_[i].tag->get_uid(); - bool uid_match = (uid.size() == existing_tag_uid.size()); - - if (uid_match) { - for (size_t i = 0; i < uid.size(); i++) { - uid_match &= (uid[i] == existing_tag_uid[i]); - } - if (uid_match) { - return i; - } - } - } - } - return nullopt; -} - -void PN7160::purge_old_tags_() { - for (size_t i = this->discovered_endpoint_.size(); i > 0; i--) { - if (millis() - this->discovered_endpoint_[i - 1].last_seen > this->tag_ttl_) { - this->erase_tag_(i - 1); - } - } -} - -void PN7160::erase_tag_(const uint8_t tag_index) { - if (tag_index < this->discovered_endpoint_.size()) { - for (auto *trigger : this->triggers_ontagremoved_) { - trigger->process(this->discovered_endpoint_[tag_index].tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_off(*this->discovered_endpoint_[tag_index].tag); - } - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); - this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); - } -} - -void PN7160::nci_fsm_transition_() { - switch (this->nci_state_) { - case NCIState::NFCC_RESET: - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - [[fallthrough]]; - - case NCIState::NFCC_INIT: - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_CONFIG); - } - [[fallthrough]]; - - case NCIState::NFCC_CONFIG: - if (this->send_init_config_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to send initial config"); - this->nci_fsm_set_error_state_(NCIState::NFCC_CONFIG); - return; - } else { - this->config_refresh_pending_ = false; - this->nci_fsm_set_state_(NCIState::NFCC_SET_DISCOVER_MAP); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_DISCOVER_MAP: - if (this->set_discover_map_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set discover map"); - this->nci_fsm_set_error_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_LISTEN_MODE_ROUTING: - if (this->set_listen_mode_routing_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set listen mode routing"); - this->nci_fsm_set_error_state_(NCIState::RFST_IDLE); - return; - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - [[fallthrough]]; - - case NCIState::RFST_IDLE: - if (this->nci_state_error_ == NCIState::RFST_DISCOVERY) { - this->stop_discovery_(); - } - - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - - if (!this->listening_enabled_ && !this->polling_enabled_) { - return; - } - - if (this->start_discovery_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to start discovery"); - this->nci_fsm_set_error_state_(NCIState::RFST_DISCOVERY); - } else { - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - } - return; - - case NCIState::RFST_W4_HOST_SELECT: - select_endpoint_(); - [[fallthrough]]; - - // All cases below are waiting for NOTIFICATION messages - case NCIState::RFST_DISCOVERY: - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - [[fallthrough]]; - - case NCIState::RFST_LISTEN_ACTIVE: - case NCIState::RFST_LISTEN_SLEEP: - case NCIState::RFST_POLL_ACTIVE: - case NCIState::EP_SELECTING: - case NCIState::EP_DEACTIVATING: - if (this->irq_pin_->digital_read()) { - this->process_message_(); - } - break; - - case NCIState::FAILED: - case NCIState::NONE: - default: - return; - } -} - -void PN7160::nci_fsm_set_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_state_(%u)", (uint8_t) new_state); - this->nci_state_ = new_state; - this->nci_state_error_ = NCIState::NONE; - this->error_count_ = 0; - this->last_nci_state_change_ = millis(); -} - -bool PN7160::nci_fsm_set_error_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_error_state_(%u); error_count_ = %u", (uint8_t) new_state, this->error_count_); - this->nci_state_error_ = new_state; - if (this->error_count_++ > NFCC_MAX_ERROR_COUNT) { - if ((this->nci_state_error_ == NCIState::NFCC_RESET) || (this->nci_state_error_ == NCIState::NFCC_INIT) || - (this->nci_state_error_ == NCIState::NFCC_CONFIG)) { - ESP_LOGE(TAG, "Too many initialization failures -- check device connections"); - this->mark_failed(); - this->nci_fsm_set_state_(NCIState::FAILED); - } else { - ESP_LOGW(TAG, "Too many errors transitioning to state %u; resetting NFCC", (uint8_t) this->nci_state_error_); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - } - } - return this->error_count_ > NFCC_MAX_ERROR_COUNT; -} - -void PN7160::process_message_() { - nfc::NciMessage rx; - if (this->read_nfcc(rx, NFCC_DEFAULT_TIMEOUT) != nfc::STATUS_OK) { - return; // No data - } - - switch (rx.get_message_type()) { - case nfc::NCI_PKT_MT_CTRL_NOTIFICATION: - if (rx.get_gid() == nfc::RF_GID) { - switch (rx.get_oid()) { - case nfc::RF_INTF_ACTIVATED_OID: - ESP_LOGVV(TAG, "RF_INTF_ACTIVATED_OID"); - this->process_rf_intf_activated_oid_(rx); - return; - - case nfc::RF_DISCOVER_OID: - ESP_LOGVV(TAG, "RF_DISCOVER_OID"); - this->process_rf_discover_oid_(rx); - return; - - case nfc::RF_DEACTIVATE_OID: - ESP_LOGVV(TAG, "RF_DEACTIVATE_OID: type: 0x%02X, reason: 0x%02X", rx.get_message()[3], rx.get_message()[4]); - this->process_rf_deactivate_oid_(rx); - return; - - default: - ESP_LOGV(TAG, "Unimplemented RF OID received: 0x%02X", rx.get_oid()); - } - } else if (rx.get_gid() == nfc::NCI_CORE_GID) { - switch (rx.get_oid()) { - case nfc::NCI_CORE_GENERIC_ERROR_OID: - ESP_LOGV(TAG, "NCI_CORE_GENERIC_ERROR_OID:"); - switch (rx.get_simple_status_response()) { - case nfc::DISCOVERY_ALREADY_STARTED: - ESP_LOGV(TAG, " DISCOVERY_ALREADY_STARTED"); - break; - - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - // Tag removed too soon - ESP_LOGV(TAG, " DISCOVERY_TARGET_ACTIVATION_FAILED"); - if (this->nci_state_ == NCIState::EP_SELECTING) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - if (!this->discovered_endpoint_.empty()) { - this->erase_tag_(this->selecting_endpoint_); - } - } else { - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - case nfc::DISCOVERY_TEAR_DOWN: - ESP_LOGV(TAG, " DISCOVERY_TEAR_DOWN"); - break; - - default: - ESP_LOGW(TAG, "Unknown error: 0x%02X", rx.get_simple_status_response()); - break; - } - break; - - default: - ESP_LOGV(TAG, "Unimplemented NCI Core OID received: 0x%02X", rx.get_oid()); - } - } else { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented notification: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - break; - - case nfc::NCI_PKT_MT_CTRL_RESPONSE: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented GID: 0x%02X OID: 0x%02X Full response: %s", rx.get_gid(), rx.get_oid(), - nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_CTRL_COMMAND: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented command: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_DATA: - this->process_data_message_(rx); - break; - - default: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented message type: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - } -} - -void PN7160::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoint was activated - uint8_t discovery_id = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_DISCOVERY_ID); - uint8_t interface = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_INTERFACE); - uint8_t protocol = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_PROTOCOL); - uint8_t mode_tech = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MODE_TECH); - uint8_t max_size = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MAX_SIZE); - - ESP_LOGVV(TAG, "Endpoint activated -- interface: 0x%02X, protocol: 0x%02X, mode&tech: 0x%02X, max payload: %u", - interface, protocol, mode_tech, max_size); - - if (mode_tech & nfc::MODE_LISTEN_MASK) { - ESP_LOGVV(TAG, "Tag activated in listen mode"); - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_ACTIVE); - return; - } - - this->nci_fsm_set_state_(NCIState::RFST_POLL_ACTIVE); - auto incoming_tag = - this->build_tag_(mode_tech, std::vector(rx.get_message().begin() + 10, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = discovery_id; - this->discovered_endpoint_[tag_loc.value()].protocol = protocol; - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag cache updated"); - } else { - this->discovered_endpoint_.emplace_back( - DiscoveredEndpoint{discovery_id, protocol, millis(), std::move(incoming_tag), false}); - tag_loc = this->discovered_endpoint_.size() - 1; - ESP_LOGVV(TAG, "Tag added to cache"); - } - - auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; - - switch (this->next_task_) { - case EP_CLEAN: - ESP_LOGD(TAG, " Tag cleaning"); - if (this->clean_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag cleaning incomplete"); - } - ESP_LOGD(TAG, " Tag cleaned!"); - break; - - case EP_FORMAT: - ESP_LOGD(TAG, " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error formatting tag as NDEF"); - } - ESP_LOGD(TAG, " Tag formatted!"); - break; - - case EP_WRITE: - if (this->next_task_message_to_write_ != nullptr) { - ESP_LOGD(TAG, " Tag writing\n" - " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag could not be formatted for writing"); - } else { - ESP_LOGD(TAG, " Writing NDEF data"); - if (this->write_endpoint_(working_endpoint.tag->get_uid(), this->next_task_message_to_write_) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, " Failed to write message to tag"); - } - ESP_LOGD(TAG, " Finished writing NDEF data"); - this->next_task_message_to_write_ = nullptr; - this->on_finished_write_callback_.call(); - } - } - break; - - case EP_READ: - default: - if (!working_endpoint.trig_called) { - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Read tag type %s with UID %s", working_endpoint.tag->get_tag_type().c_str(), - nfc::format_uid_to(uid_buf, working_endpoint.tag->get_uid())); - if (this->read_endpoint_data_(*working_endpoint.tag) != nfc::STATUS_OK) { - ESP_LOGW(TAG, " Unable to read NDEF record(s)"); - } else if (working_endpoint.tag->has_ndef_message()) { - const auto message = working_endpoint.tag->get_ndef_message(); - const auto records = message->get_records(); - ESP_LOGD(TAG, " NDEF record(s):"); - for (const auto &record : records) { - ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); - } - } else { - ESP_LOGW(TAG, " No NDEF records found"); - } - for (auto *trigger : this->triggers_ontag_) { - trigger->process(working_endpoint.tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_on(*working_endpoint.tag); - } - working_endpoint.trig_called = true; - break; - } - } - if (working_endpoint.tag->get_tag_type() == nfc::MIFARE_CLASSIC) { - this->halt_mifare_classic_tag_(); - } - } - if (this->next_task_ != EP_READ) { - this->read_mode(); - } - - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); -} - -void PN7160::process_rf_discover_oid_(nfc::NciMessage &rx) { - auto incoming_tag = this->build_tag_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), - std::vector(rx.get_message().begin() + 7, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag!"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); - this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag found & updated"); - } else { - this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), - rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - millis(), std::move(incoming_tag), false}); - ESP_LOGVV(TAG, "Tag saved"); - } - } - - if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - ESP_LOGVV(TAG, "Discovered %u endpoints", this->discovered_endpoint_.size()); - } -} - -void PN7160::process_rf_deactivate_oid_(nfc::NciMessage &rx) { - this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; - - switch (rx.get_simple_status_response()) { - case nfc::DEACTIVATION_TYPE_DISCOVERY: - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - break; - - case nfc::DEACTIVATION_TYPE_IDLE: - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - break; - - case nfc::DEACTIVATION_TYPE_SLEEP: - case nfc::DEACTIVATION_TYPE_SLEEP_AF: - if (this->nci_state_ == NCIState::RFST_LISTEN_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_SLEEP); - } else if (this->nci_state_ == NCIState::RFST_POLL_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - default: - break; - } -} - -void PN7160::process_data_message_(nfc::NciMessage &rx) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - - std::vector ndef_response; - this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); - - uint16_t ndef_response_size = ndef_response.size(); - if (!ndef_response_size) { - return; // no message returned, we cannot respond - } - - std::vector tx_msg = {nfc::NCI_PKT_MT_DATA, uint8_t((ndef_response_size & 0xFF00) >> 8), - uint8_t(ndef_response_size & 0x00FF)}; - tx_msg.insert(tx_msg.end(), ndef_response.begin(), ndef_response.end()); - nfc::NciMessage tx(tx_msg); - ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending reply for card emulation failed"); - } -} - -void PN7160::card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response) { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); - ndef_response.clear(); - return; - } - - if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_APP_SELECT))) { - // CARD_EMU_T4T_APP_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_APP_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_APP_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_CC_SELECT))) { - // CARD_EMU_T4T_CC_SELECT - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_APP_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_CC_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_CC_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_NDEF_SELECT))) { - // CARD_EMU_T4T_NDEF_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_READ), - std::begin(CARD_EMU_T4T_READ))) { - // CARD_EMU_T4T_READ - if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - if (length <= (sizeof(CARD_EMU_T4T_CC) + offset + 2)) { - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_CC) + offset, - std::begin(CARD_EMU_T4T_CC) + offset + length); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED"); - auto ndef_message = this->card_emulation_message_->encode(); - uint16_t ndef_msg_size = ndef_message.size(); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - char ndef_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Encoded NDEF message: %s", nfc::format_bytes_to(ndef_buf, ndef_message)); - - if (length <= (ndef_msg_size + offset + 2)) { - if (offset == 0) { - ndef_response.resize(2); - ndef_response[0] = (ndef_msg_size & 0xFF00) >> 8; - ndef_response[1] = (ndef_msg_size & 0x00FF); - if (length > 2) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 2); - } - } else if (offset == 1) { - ndef_response.resize(1); - ndef_response[0] = (ndef_msg_size & 0x00FF); - if (length > 1) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 1); - } - } else { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length); - } - - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - - if ((offset + length) >= (ndef_msg_size + 2)) { - ESP_LOGD(TAG, "NDEF message sent"); - this->on_emulated_tag_scan_callback_.call(); - } - } - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_WRITE), - std::begin(CARD_EMU_T4T_WRITE))) { - // CARD_EMU_T4T_WRITE - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - std::vector ndef_msg_written; - - ndef_msg_written.insert(ndef_msg_written.end(), response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5 + length); - char write_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(write_buf, ndef_msg_written)); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } -} - -uint8_t PN7160::transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, const uint16_t timeout, - const bool expect_notification) { - uint8_t retries = NFCC_MAX_COMM_FAILS; - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - - while (retries) { - // first, send the message we need to send - if (this->write_nfcc(tx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending message"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Wrote: %s", nfc::format_bytes_to(buf, tx.get_message())); - // next, the NFCC should send back a response - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error receiving message"); - if (!retries--) { - ESP_LOGE(TAG, " ...giving up"); - return nfc::STATUS_FAILED; - } - } else { - break; - } - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - // validate the response based on the message type that was sent (command vs. data) - if (!tx.message_type_is(nfc::NCI_PKT_MT_DATA)) { - // for commands, the GID and OID should match and the status should be OK - if ((rx.get_gid() != tx.get_gid()) || (rx.get_oid()) != tx.get_oid()) { - ESP_LOGE(TAG, "Incorrect response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - ESP_LOGE(TAG, "Error in response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - return rx.get_simple_status_response(); - } else { - // when requesting data from the endpoint, the first response is from the NFCC; we must validate this, first - if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.gid_is(nfc::NCI_CORE_GID)) || - (!rx.oid_is(nfc::NCI_CORE_CONN_CREDITS_OID)) || (!rx.message_length_is(3))) { - ESP_LOGE(TAG, "Incorrect response to data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (expect_notification) { - // if the NFCC said "OK", there will be additional data to read; this comes back in a notification message - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error receiving data from endpoint"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - - return nfc::STATUS_OK; - } -} - -uint8_t PN7160::wait_for_irq_(uint16_t timeout, bool pin_state) { - auto start_time = millis(); - - while (millis() - start_time < timeout) { - if (this->irq_pin_->digital_read() == pin_state) { - return nfc::STATUS_OK; - } - } - ESP_LOGW(TAG, "Timed out waiting for IRQ state"); - return nfc::STATUS_FAILED; -} - } // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160.h b/esphome/components/pn7160/pn7160.h index da4577874ca..b44a595cced 100644 --- a/esphome/components/pn7160/pn7160.h +++ b/esphome/components/pn7160/pn7160.h @@ -1,59 +1,9 @@ #pragma once -#include "esphome/components/nfc/automation.h" -#include "esphome/components/nfc/nci_core.h" -#include "esphome/components/nfc/nci_message.h" -#include "esphome/components/nfc/nfc.h" -#include "esphome/components/nfc/nfc_helpers.h" -#include "esphome/core/component.h" -#include "esphome/core/gpio.h" -#include "esphome/core/helpers.h" - -#include +#include "esphome/components/pn71xx/pn71xx.h" namespace esphome::pn7160 { -static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 10; -static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; -static constexpr uint16_t NFCC_TAG_WRITE_TIMEOUT = 15; - -static constexpr uint8_t NFCC_MAX_COMM_FAILS = 3; -static constexpr uint8_t NFCC_MAX_ERROR_COUNT = 10; - -static constexpr uint8_t XCHG_DATA_OID = 0x10; -static constexpr uint8_t MF_SECTORSEL_OID = 0x32; -static constexpr uint8_t MFC_AUTHENTICATE_OID = 0x40; -static constexpr uint8_t TEST_PRBS_OID = 0x30; -static constexpr uint8_t TEST_ANTENNA_OID = 0x3D; -static constexpr uint8_t TEST_GET_REGISTER_OID = 0x33; - -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_A = 0x00; // key select A -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_B = 0x80; // key select B -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_EMBED_KEY = 0x10; - -static constexpr uint8_t CARD_EMU_T4T_APP_SELECT[] = {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, - 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC[] = {0x00, 0x0F, 0x20, 0x00, 0xFF, 0x00, 0xFF, 0x04, - 0x06, 0xE1, 0x04, 0x00, 0xFF, 0x00, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}; -static constexpr uint8_t CARD_EMU_T4T_NDEF_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}; -static constexpr uint8_t CARD_EMU_T4T_READ[] = {0x00, 0xB0}; -static constexpr uint8_t CARD_EMU_T4T_WRITE[] = {0x00, 0xD6}; -static constexpr uint8_t CARD_EMU_T4T_OK[] = {0x90, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_NOK[] = {0x6A, 0x82}; - -static constexpr uint8_t CORE_CONFIG_SOLO[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0x01, // TOTAL_DURATION (low)... - 0x00}; // TOTAL_DURATION (high): 1 ms - -static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0xF8, // TOTAL_DURATION (low)... - 0x02}; // TOTAL_DURATION (high): 760 ms - static constexpr uint8_t PMU_CFG[] = { 0x01, // Number of parameters 0xA0, 0x0E, // ext. tag @@ -73,34 +23,6 @@ static constexpr uint8_t PMU_CFG[] = { 0x0C, // RFU }; -static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes - nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T3T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_ISODEP, nfc::RF_DISCOVER_MAP_MODE_POLL | nfc::RF_DISCOVER_MAP_MODE_LISTEN, - nfc::INTF_ISODEP, // poll & listen mode - nfc::PROT_MIFARE, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_TAGCMD}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_LISTEN_CONFIG[] = { - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - -static constexpr uint8_t RF_DISCOVERY_POLL_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF, // poll mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (another message is coming) 2, // number of table entries 0x01, // type = protocol-based @@ -114,200 +36,23 @@ static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (an 0x07, // power state nfc::TECH_PASSIVE_NFCA}; // technology -enum class CardEmulationState : uint8_t { - CARD_EMU_IDLE, - CARD_EMU_NDEF_APP_SELECTED, - CARD_EMU_CC_SELECTED, - CARD_EMU_NDEF_SELECTED, - CARD_EMU_DESFIRE_PROD, -}; - -enum class NCIState : uint8_t { - NONE = 0x00, - NFCC_RESET, - NFCC_INIT, - NFCC_CONFIG, - NFCC_SET_DISCOVER_MAP, - NFCC_SET_LISTEN_MODE_ROUTING, - RFST_IDLE, - RFST_DISCOVERY, - RFST_W4_ALL_DISCOVERIES, - RFST_W4_HOST_SELECT, - RFST_LISTEN_ACTIVE, - RFST_LISTEN_SLEEP, - RFST_POLL_ACTIVE, - EP_DEACTIVATING, - EP_SELECTING, - TEST = 0xFE, - FAILED = 0xFF, -}; - -enum class TestMode : uint8_t { - TEST_NONE = 0x00, - TEST_PRBS, - TEST_ANTENNA, - TEST_GET_REGISTER, -}; - -struct DiscoveredEndpoint { - uint8_t id; - uint8_t protocol; - uint32_t last_seen; - std::unique_ptr tag; - bool trig_called; -}; - -class PN7160 : public nfc::Nfcc, public Component { +class PN7160 : public pn71xx::PN71xx { public: void setup() override; void dump_config() override; - void loop() override; void set_dwl_req_pin(GPIOPin *dwl_req_pin) { this->dwl_req_pin_ = dwl_req_pin; } - void set_irq_pin(GPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } - void set_ven_pin(GPIOPin *ven_pin) { this->ven_pin_ = ven_pin; } void set_wkup_req_pin(GPIOPin *wkup_req_pin) { this->wkup_req_pin_ = wkup_req_pin; } - void set_tag_ttl(uint32_t ttl) { this->tag_ttl_ = ttl; } - void set_tag_emulation_message(std::shared_ptr message); - void set_tag_emulation_message(const optional &message, optional include_android_app_record); - void set_tag_emulation_message(const char *message, bool include_android_app_record = true); - void set_tag_emulation_off(); - void set_tag_emulation_on(); - bool tag_emulation_enabled() { return this->listening_enabled_; } - - void set_polling_off(); - void set_polling_on(); - bool polling_enabled() { return this->polling_enabled_; } - - void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } - void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } - - template void add_on_emulated_tag_scan_callback(F &&callback) { - this->on_emulated_tag_scan_callback_.add(std::forward(callback)); - } - - template void add_on_finished_write_callback(F &&callback) { - this->on_finished_write_callback_.add(std::forward(callback)); - } - - bool is_writing() { return this->next_task_ != EP_READ; }; - - void read_mode(); - void clean_mode(); - void format_mode(); - void write_mode(); - void set_tag_write_message(std::shared_ptr message); - void set_tag_write_message(optional message, optional include_android_app_record); - - uint8_t set_test_mode(TestMode test_mode, const std::vector &data, std::vector &result); - protected: - uint8_t reset_core_(bool reset_config, bool power); - uint8_t init_core_(); - uint8_t send_init_config_(); - uint8_t send_core_config_(); - uint8_t refresh_core_config_(); - - uint8_t set_discover_map_(); - - uint8_t set_listen_mode_routing_(); - - uint8_t start_discovery_(); - uint8_t stop_discovery_(); - uint8_t deactivate_(uint8_t type, uint16_t timeout = NFCC_DEFAULT_TIMEOUT); - - void select_endpoint_(); - - uint8_t read_endpoint_data_(nfc::NfcTag &tag); - uint8_t clean_endpoint_(nfc::NfcTagUid &uid); - uint8_t format_endpoint_(nfc::NfcTagUid &uid); - uint8_t write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message); - - std::unique_ptr build_tag_(uint8_t mode_tech, const std::vector &data); - optional find_tag_uid_(const nfc::NfcTagUid &uid); - void purge_old_tags_(); - void erase_tag_(uint8_t tag_index); - - /// advance controller state as required - void nci_fsm_transition_(); - /// set new controller state - void nci_fsm_set_state_(NCIState new_state); - /// setting controller to this state caused an error; returns true if too many errors/failures - bool nci_fsm_set_error_state_(NCIState new_state); - /// parse & process incoming messages from the NFCC - void process_message_(); - void process_rf_intf_activated_oid_(nfc::NciMessage &rx); - void process_rf_discover_oid_(nfc::NciMessage &rx); - void process_rf_deactivate_oid_(nfc::NciMessage &rx); - void process_data_message_(nfc::NciMessage &rx); - - void card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response); - - uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, - bool expect_notification = true); - virtual uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) = 0; - virtual uint8_t write_nfcc(nfc::NciMessage &tx) = 0; - - uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); - - uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); - uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); - uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); - uint8_t sect_to_auth_(uint8_t block_num); - uint8_t format_mifare_classic_mifare_(); - uint8_t format_mifare_classic_ndef_(); - uint8_t write_mifare_classic_tag_(const std::shared_ptr &message); - uint8_t halt_mifare_classic_tag_(); - - uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); - uint16_t read_mifare_ultralight_capacity_(); - uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, - uint8_t &message_start_index); - uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); - uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); - uint8_t clean_mifare_ultralight_(); - - enum NfcTask : uint8_t { - EP_READ = 0, - EP_CLEAN, - EP_FORMAT, - EP_WRITE, - } next_task_{EP_READ}; - - bool config_refresh_pending_{false}; - bool core_config_is_solo_{false}; - bool listening_enabled_{false}; - bool polling_enabled_{true}; - - uint8_t error_count_{0}; - uint8_t fail_count_{0}; - uint32_t last_nci_state_change_{0}; - uint8_t selecting_endpoint_{0}; - uint32_t tag_ttl_{250}; + void prepare_reset() override; + uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override; + uint8_t process_init_response(nfc::NciMessage &rx) override; + std::span pmu_config() const override { return PMU_CFG; } + std::span listen_mode_routing_config() const override { return RF_LISTEN_MODE_ROUTING_CONFIG; } GPIOPin *dwl_req_pin_{nullptr}; - GPIOPin *irq_pin_{nullptr}; - GPIOPin *ven_pin_{nullptr}; GPIOPin *wkup_req_pin_{nullptr}; - - CallbackManager on_emulated_tag_scan_callback_; - CallbackManager on_finished_write_callback_; - - std::vector discovered_endpoint_; - - CardEmulationState ce_state_{CardEmulationState::CARD_EMU_IDLE}; - NCIState nci_state_{NCIState::NFCC_RESET}; - NCIState nci_state_error_{NCIState::NONE}; - - std::shared_ptr card_emulation_message_; - std::shared_ptr next_task_message_to_write_; - - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; }; } // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160_mifare_classic.cpp b/esphome/components/pn7160/pn7160_mifare_classic.cpp deleted file mode 100644 index 0dc8bbdfe46..00000000000 --- a/esphome/components/pn7160/pn7160_mifare_classic.cpp +++ /dev/null @@ -1,326 +0,0 @@ -#include -#include - -#include "pn7160.h" -#include "esphome/core/log.h" - -namespace esphome::pn7160 { - -static const char *const TAG = "pn7160.mifare_classic"; - -uint8_t PN7160::read_mifare_classic_tag_(nfc::NfcTag &tag) { - uint8_t current_block = 4; - uint8_t message_start_index = 0; - uint32_t message_length = 0; - - if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Tag auth failed while attempting to read tag data"); - return nfc::STATUS_FAILED; - } - std::vector data; - - if (this->read_mifare_classic_block_(current_block, data) == nfc::STATUS_OK) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { - return nfc::STATUS_FAILED; - } - } else { - ESP_LOGE(TAG, "Failed to read block %u", current_block); - return nfc::STATUS_FAILED; - } - - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; - - while (index < buffer_size) { - if (nfc::mifare_classic_is_first_block(current_block)) { - if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Block authentication failed for %u", current_block); - return nfc::STATUS_FAILED; - } - } - std::vector block_data; - if (this->read_mifare_classic_block_(current_block, block_data) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error reading block %u", current_block); - return nfc::STATUS_FAILED; - } else { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); - } - - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; - current_block++; - - if (nfc::mifare_classic_is_trailer_block(current_block)) { - current_block++; - } - } - - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { - return nfc::STATUS_FAILED; - } - - tag.set_ndef_message(make_unique(buffer)); - - return nfc::STATUS_OK; -} - -uint8_t PN7160::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - - ESP_LOGVV(TAG, "Read XCHG_DATA_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Timeout reading tag data"); - return nfc::STATUS_FAILED; - } - - if ((!rx.message_type_is(nfc::NCI_PKT_MT_DATA)) || (!rx.simple_status_response_is(XCHG_DATA_OID)) || - (!rx.message_length_is(18))) { - ESP_LOGE(TAG, "MFC read block failed - block 0x%02x", block_num); - ESP_LOGV(TAG, "Read response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - data.insert(data.begin(), rx.get_message().begin() + 4, rx.get_message().end() - 1); - - ESP_LOGVV(TAG, " Block %u: %s", block_num, nfc::format_bytes_to(buf, data)); - return nfc::STATUS_OK; -} - -uint8_t PN7160::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {MFC_AUTHENTICATE_OID, this->sect_to_auth_(block_num), key_num}); - - switch (key_num) { - case nfc::MIFARE_CMD_AUTH_A: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_A; - break; - - case nfc::MIFARE_CMD_AUTH_B: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_B; - break; - - default: - break; - } - - if (key != nullptr) { - tx.get_message().back() |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; - tx.get_message().insert(tx.get_message().end(), key, key + 6); - } - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "MFC_AUTHENTICATE_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending MFC_AUTHENTICATE_REQ failed"); - return nfc::STATUS_FAILED; - } - if ((!rx.message_type_is(nfc::NCI_PKT_MT_DATA)) || (!rx.simple_status_response_is(MFC_AUTHENTICATE_OID)) || - (rx.get_message()[4] != nfc::STATUS_OK)) { - ESP_LOGE(TAG, "MFC authentication failed - block 0x%02x", block_num); - ESP_LOGVV(TAG, "MFC_AUTHENTICATE_RSP: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - ESP_LOGV(TAG, "MFC block %u authentication succeeded", block_num); - return nfc::STATUS_OK; -} - -uint8_t PN7160::sect_to_auth_(const uint8_t block_num) { - const uint8_t first_high_block = nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW * nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; - if (block_num >= first_high_block) { - return ((block_num - first_high_block) / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_HIGH) + - nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; - } - return block_num / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW; -} - -uint8_t PN7160::format_mifare_classic_mifare_() { - static constexpr std::array BLANK_BUFFER = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - static constexpr std::array TRAILER_BUFFER = { - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x80, 0x69, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - - auto status = nfc::STATUS_OK; - - for (int block = 0; block < 64; block += 4) { - if (this->auth_mifare_classic_block_(block + 3, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY) != nfc::STATUS_OK) { - continue; - } - if (block != 0) { - if (this->write_mifare_classic_block_(block, BLANK_BUFFER.data(), BLANK_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block); - status = nfc::STATUS_FAILED; - } - } - if (this->write_mifare_classic_block_(block + 1, BLANK_BUFFER.data(), BLANK_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 1); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 2, BLANK_BUFFER.data(), BLANK_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 2); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER.data(), TRAILER_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 3); - status = nfc::STATUS_FAILED; - } - } - - return status; -} - -uint8_t PN7160::format_mifare_classic_ndef_() { - static constexpr std::array EMPTY_NDEF_MESSAGE = { - 0x03, 0x03, 0xD0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - static constexpr std::array BLANK_BLOCK = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - static constexpr std::array BLOCK_1_DATA = { - 0x14, 0x01, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1}; - static constexpr std::array BLOCK_2_DATA = { - 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1}; - static constexpr std::array BLOCK_3_TRAILER = { - 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x78, 0x77, 0x88, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - static constexpr std::array NDEF_TRAILER = { - 0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7, 0x7F, 0x07, 0x88, 0x40, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - - if (this->auth_mifare_classic_block_(0, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to authenticate block 0 for formatting"); - return nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(1, BLOCK_1_DATA.data(), BLOCK_1_DATA.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(2, BLOCK_2_DATA.data(), BLOCK_2_DATA.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(3, BLOCK_3_TRAILER.data(), BLOCK_3_TRAILER.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - - ESP_LOGD(TAG, "Sector 0 formatted with NDEF"); - - auto status = nfc::STATUS_OK; - - for (int block = 4; block < 64; block += 4) { - if (this->auth_mifare_classic_block_(block + 3, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - if (block == 4) { - if (this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE.data(), EMPTY_NDEF_MESSAGE.size()) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block); - status = nfc::STATUS_FAILED; - } - } else { - if (this->write_mifare_classic_block_(block, BLANK_BLOCK.data(), BLANK_BLOCK.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block); - status = nfc::STATUS_FAILED; - } - } - if (this->write_mifare_classic_block_(block + 1, BLANK_BLOCK.data(), BLANK_BLOCK.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 1); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 2, BLANK_BLOCK.data(), BLANK_BLOCK.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 2); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 3, NDEF_TRAILER.data(), NDEF_TRAILER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write trailer block %u", block + 3); - status = nfc::STATUS_FAILED; - } - } - return status; -} - -uint8_t PN7160::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_WRITE, block_num}); - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - - ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 1: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending XCHG_DATA_REQ failed"); - return nfc::STATUS_FAILED; - } - // write command part two - tx.set_payload({XCHG_DATA_OID}); - tx.get_message().insert(tx.get_message().end(), data, data + len); - - ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 2: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "MFC XCHG_DATA timed out waiting for XCHG_DATA_RSP during block write"); - return nfc::STATUS_FAILED; - } - - if ((!rx.message_type_is(nfc::NCI_PKT_MT_DATA)) || (!rx.simple_status_response_is(XCHG_DATA_OID)) || - (rx.get_message()[4] != nfc::MIFARE_CMD_ACK)) { - ESP_LOGE(TAG, "MFC write block failed - block 0x%02x", block_num); - ESP_LOGV(TAG, "Write response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - return nfc::STATUS_OK; -} - -uint8_t PN7160::write_mifare_classic_tag_(const std::shared_ptr &message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); - - uint32_t index = 0; - uint8_t current_block = 4; - - while (index < buffer_length) { - if (nfc::mifare_classic_is_first_block(current_block)) { - if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - } - - if (this->write_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE) != - nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; - current_block++; - - if (nfc::mifare_classic_is_trailer_block(current_block)) { - // Skipping as cannot write to trailer - current_block++; - } - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::halt_mifare_classic_tag_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_HALT, 0}); - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Halt XCHG_DATA_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending halt XCHG_DATA_REQ failed"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index c34cf90e68c..d559c6c2517 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -12,28 +12,26 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); if (!this->read_bytes_raw(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE)) { return nfc::STATUS_FAILED; } - uint8_t length = rx.get_payload_size(); - if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); - if (!this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { - return nfc::STATUS_FAILED; - } - } - // semaphore to ensure transaction is complete before returning - if (this->wait_for_irq_(pn7160::NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "read_nfcc_() post-read timeout waiting for IRQ line to clear"); + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); + if (length > 0 && !this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { return nfc::STATUS_FAILED; } + // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short + // low pulse may be missed; that means more data is waiting, not that this read failed (UM11495, 6.2.4). + if (this->wait_for_irq_(pn71xx::NFCC_IRQ_CLEAR_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGVV(TAG, "IRQ still active after read; another message is pending"); + } return nfc::STATUS_OK; } uint8_t PN7160I2C::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index f3c413e9520..85a6c1fb7d0 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -7,7 +7,6 @@ static const char *const TAG = "pn7160_spi"; void PN7160Spi::setup() { this->spi_setup(); - this->cs_->digital_write(false); PN7160::setup(); } @@ -17,31 +16,39 @@ uint8_t PN7160Spi::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); this->enable(); this->write_byte(TDD_SPI_READ); // send "transfer direction detector" this->read_array(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE); - uint8_t length = rx.get_payload_size(); + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); this->read_array(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length); } this->disable(); - // semaphore to ensure transaction is complete before returning - if (this->wait_for_irq_(pn7160::NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "read_nfcc_() post-read timeout waiting for IRQ line to clear"); - return nfc::STATUS_FAILED; + // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short + // low pulse may be missed; that means more data is waiting, not that this read failed (UM11495, 6.3.4). + if (this->wait_for_irq_(pn71xx::NFCC_IRQ_CLEAR_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGVV(TAG, "IRQ still active after read; another message is pending"); } return nfc::STATUS_OK; } uint8_t PN7160Spi::write_nfcc(nfc::NciMessage &tx) { + const auto encoded = tx.encode(); this->enable(); - this->write_byte(TDD_SPI_WRITE); // send "transfer direction detector" - this->write_array(tx.encode().data(), tx.encode().size()); + // send "transfer direction detector"; the NFCC answers 0xFF when it is ready to receive (UM11495, 6.3.3) + const uint8_t status = this->transfer_byte(TDD_SPI_WRITE); + const bool ready = status == 0xFF; + if (ready) { + this->write_array(encoded.data(), encoded.size()); + } this->disable(); - return nfc::STATUS_OK; + if (!ready) { + ESP_LOGV(TAG, "NFCC not ready for write (0x%02X)", status); + } + return ready ? nfc::STATUS_OK : nfc::STATUS_FAILED; } void PN7160Spi::dump_config() { diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py new file mode 100644 index 00000000000..8472d4192c4 --- /dev/null +++ b/esphome/components/pn71xx/__init__.py @@ -0,0 +1,158 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import nfc +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_IRQ_PIN, + CONF_MESSAGE, + CONF_ON_FINISHED_WRITE, + CONF_ON_TAG, + CONF_ON_TAG_REMOVED, + CONF_TRIGGER_ID, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["binary_sensor", "nfc"] +CODEOWNERS = ["@kbx81", "@jesserockz"] + +CONF_EMULATION_MESSAGE = "emulation_message" +CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" +CONF_ON_EMULATED_TAG_SCAN = "on_emulated_tag_scan" +CONF_TAG_TTL = "tag_ttl" +CONF_VEN_PIN = "ven_pin" + +pn71xx_ns = cg.esphome_ns.namespace("pn71xx") +PN71xx = pn71xx_ns.class_("PN71xx", nfc.Nfcc, cg.Component) + +SIMPLE_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PN71xx), + } +) + +SET_MESSAGE_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(PN71xx), + cv.Required(CONF_MESSAGE): cv.templatable(cv.string), + cv.Optional(CONF_INCLUDE_ANDROID_APP_RECORD, default=True): cv.boolean, + } +) + +PN71XX_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), + cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), + cv.Optional(CONF_ON_TAG): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), + } + ), + cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), + } + ), + cv.Required(CONF_IRQ_PIN): pins.gpio_input_pin_schema, + cv.Required(CONF_VEN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_EMULATION_MESSAGE): cv.string, + cv.Optional(CONF_TAG_TTL): cv.positive_time_period_milliseconds, + } +).extend(cv.COMPONENT_SCHEMA) + + +for _name, _method in ( + ("tag.set_emulation_message", "set_tag_emulation_message"), + ("tag.set_write_message", "set_tag_write_message"), +): + automation.register_apply_action( + _name, + SET_MESSAGE_ACTION_SCHEMA, + automation.ApplyCall( + f"{_method}({{}}, {{}})", + ( + (CONF_MESSAGE, cg.std_string), + (CONF_INCLUDE_ANDROID_APP_RECORD, cg.bool_), + ), + ), + ) + +for _name, _call in ( + ("tag.emulation_off", "set_tag_emulation_off()"), + ("tag.emulation_on", "set_tag_emulation_on()"), + ("tag.polling_off", "set_polling_off()"), + ("tag.polling_on", "set_polling_on()"), + ("tag.set_clean_mode", "clean_mode()"), + ("tag.set_format_mode", "format_mode()"), + ("tag.set_read_mode", "read_mode()"), + ("tag.set_write_mode", "write_mode()"), +): + automation.register_apply_action( + _name, SIMPLE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +def register_is_writing_condition(name: str, chip_class: MockObj) -> None: + """Register the chip-specific ``.is_writing`` condition.""" + automation.register_apply_condition( + name, + cv.Schema( + { + cv.GenerateID(): cv.use_id(chip_class), + } + ), + "is_writing()", + ) + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + +_request_ontag_trigger_slot = cg.slot_counter("PN71XX_ON_TAG_TRIGGER_COUNT") +_request_ontagremoved_trigger_slot = cg.slot_counter( + "PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT" +) + + +async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) + cg.add(var.set_irq_pin(pin)) + + pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) + cg.add(var.set_ven_pin(pin)) + + if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): + cg.add(var.set_tag_emulation_message(emulation_message_config)) + cg.add(var.set_tag_emulation_on()) + + if (tag_ttl := config.get(CONF_TAG_TTL)) is not None: + cg.add(var.set_tag_ttl(tag_ttl)) + + for conf in config.get(CONF_ON_TAG, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontag_trigger_slot(str(var)) + cg.add(var.register_ontag_trigger(trigger)) + await automation.build_automation( + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf + ) + + for conf in config.get(CONF_ON_TAG_REMOVED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontagremoved_trigger_slot(str(var)) + cg.add(var.register_ontagremoved_trigger(trigger)) + await automation.build_automation( + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf + ) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp new file mode 100644 index 00000000000..6a24acd843e --- /dev/null +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -0,0 +1,1188 @@ +#include +#include + +#include "pn71xx.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::pn71xx { + +static const char *const TAG = "pn71xx"; + +// Builds a message with a URI record and, optionally, the Home Assistant Android app record +static std::unique_ptr build_uri_message(const std::string &uri, + const bool include_android_app_record) { + auto ndef_message = make_unique(); + + ndef_message->add_uri_record(uri); + + if (include_android_app_record) { + auto ext_record = make_unique(); + ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); + ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); + ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); + ndef_message->add_record(std::move(ext_record)); + } + return ndef_message; +} + +void PN71xx::setup() { + this->irq_pin_->setup(); + this->ven_pin_->setup(); + + this->nci_fsm_transition_(); // kick off reset & init processes +} + +void PN71xx::dump_config() { + LOG_PIN(" IRQ pin: ", this->irq_pin_); + LOG_PIN(" VEN pin: ", this->ven_pin_); +} + +void PN71xx::loop() { + this->nci_fsm_transition_(); + this->purge_old_tags_(); +} + +void PN71xx::set_tag_emulation_message(const std::shared_ptr &message) { + if (message == nullptr) { + return; + } + // encoded once here so it is validated up front and not re-encoded for every read from the reader + auto encoded = message->encode(); + if (encoded.size() > CARD_EMU_T4T_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "Tag emulation message too long: %zu > %u bytes", encoded.size(), CARD_EMU_T4T_MAX_NDEF_SIZE); + return; + } + this->card_emulation_ndef_.init(encoded.size()); + for (const uint8_t byte : encoded) { + this->card_emulation_ndef_.push_back(byte); + } + ESP_LOGD(TAG, "Tag emulation message set"); +} + +void PN71xx::set_tag_emulation_message(const std::string &message, const bool include_android_app_record) { + this->set_tag_emulation_message(build_uri_message(message, include_android_app_record)); +} + +void PN71xx::set_tag_emulation_message(const char *message, const bool include_android_app_record) { + this->set_tag_emulation_message(std::string(message), include_android_app_record); +} + +void PN71xx::set_tag_emulation_off() { + if (this->listening_enabled_) { + this->listening_enabled_ = false; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag emulation disabled"); +} + +void PN71xx::set_tag_emulation_on() { + if (this->card_emulation_ndef_.empty()) { + ESP_LOGE(TAG, "No NDEF message is set; tag emulation cannot be enabled"); + return; + } + if (!this->listening_enabled_) { + this->listening_enabled_ = true; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag emulation enabled"); +} + +void PN71xx::set_polling_off() { + if (this->polling_enabled_) { + this->polling_enabled_ = false; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag polling disabled"); +} + +void PN71xx::set_polling_on() { + if (!this->polling_enabled_) { + this->polling_enabled_ = true; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag polling enabled"); +} + +void PN71xx::read_mode() { + this->next_task_ = EP_READ; + ESP_LOGD(TAG, "Waiting to read next tag"); +} + +void PN71xx::clean_mode() { + this->next_task_ = EP_CLEAN; + ESP_LOGD(TAG, "Waiting to clean next tag"); +} + +void PN71xx::format_mode() { + this->next_task_ = EP_FORMAT; + ESP_LOGD(TAG, "Waiting to format next tag"); +} + +void PN71xx::write_mode() { + if (this->next_task_message_to_write_ == nullptr) { + ESP_LOGW(TAG, "Message to write must be set before setting write mode"); + return; + } + + this->next_task_ = EP_WRITE; + ESP_LOGD(TAG, "Waiting to write next tag"); +} + +void PN71xx::set_tag_write_message(std::shared_ptr message) { + this->next_task_message_to_write_ = std::move(message); + ESP_LOGD(TAG, "Message to write has been set"); +} + +void PN71xx::set_tag_write_message(const std::string &message, const bool include_android_app_record) { + this->set_tag_write_message(build_uri_message(message, include_android_app_record)); +} + +uint8_t PN71xx::set_test_mode(const TestMode test_mode, const std::vector &data, + std::vector &result) { + auto test_oid = TEST_PRBS_OID; + + switch (test_mode) { + case TestMode::TEST_PRBS: + // test_oid = TEST_PRBS_OID; + break; + + case TestMode::TEST_ANTENNA: + test_oid = TEST_ANTENNA_OID; + break; + + case TestMode::TEST_GET_REGISTER: + test_oid = TEST_GET_REGISTER_OID; + break; + + case TestMode::TEST_NONE: + default: + ESP_LOGD(TAG, "Exiting test mode"); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + return nfc::STATUS_OK; + } + + if (this->reset_core_(true, true) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to reset NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); + result.clear(); + return nfc::STATUS_FAILED; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_INIT); + } + if (this->init_core_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to initialise NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); + result.clear(); + return nfc::STATUS_FAILED; + } else { + this->nci_fsm_set_state_(NCIState::TEST); + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, test_oid, data); + + ESP_LOGW(TAG, "Starting test mode, OID 0x%02X", test_oid); + auto status = this->transceive_(tx, rx, NFCC_INIT_TIMEOUT); + + if (status != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to start test mode, OID 0x%02X", test_oid); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + result.clear(); + } else { + // the payload after the status byte, if the NFCC sent one + const auto payload = rx.get_payload(); + result.assign(payload.begin() + std::min(1, payload.size()), payload.end()); + if (!result.empty()) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); + } + } + return status; +} + +uint8_t PN71xx::reset_core_(const bool reset_config, const bool power) { + this->prepare_reset(); + + if (power) { + this->ven_pin_->digital_write(true); + delay(NFCC_RESET_DELAY); + this->ven_pin_->digital_write(false); + delay(NFCC_RESET_DELAY); + this->ven_pin_->digital_write(true); + delay(NFCC_INIT_TIMEOUT); + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_RESET_OID, + {(uint8_t) reset_config}); + + if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending reset command"); + return nfc::STATUS_FAILED; + } + + if (!rx.simple_status_response_is(nfc::STATUS_OK)) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGE(TAG, "Invalid reset response: %s", nfc::format_bytes_to(buf, rx.get_message())); + return rx.get_simple_status_response(); + } + return this->verify_reset(rx, reset_config); +} + +uint8_t PN71xx::init_core_() { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_INIT_OID); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending initialise command"); + return nfc::STATUS_FAILED; + } + + if (!rx.simple_status_response_is(nfc::STATUS_OK)) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGE(TAG, "Invalid initialise response: %s", nfc::format_bytes_to(buf, rx.get_message())); + return nfc::STATUS_FAILED; + } + + return this->process_init_response(rx); +} + +uint8_t PN71xx::send_init_config_() { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, nfc::NCI_CORE_SET_CONFIG_OID); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error enabling proprietary extensions"); + return nfc::STATUS_FAILED; + } + + tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, this->pmu_config()); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending PMU config"); + return nfc::STATUS_FAILED; + } + + return this->send_core_config_(); +} + +uint8_t PN71xx::send_core_config_() { + std::span core_config = CORE_CONFIG_SOLO; + this->core_config_is_solo_ = true; + + if (this->listening_enabled_ && this->polling_enabled_) { + core_config = CORE_CONFIG_RW_CE; + this->core_config_is_solo_ = false; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, core_config); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGW(TAG, "Error sending core config"); + return nfc::STATUS_FAILED; + } + + return nfc::STATUS_OK; +} + +uint8_t PN71xx::refresh_core_config_() { + bool core_config_should_be_solo = !(this->listening_enabled_ && this->polling_enabled_); + + if (this->nci_state_ == NCIState::RFST_DISCOVERY) { + if (this->stop_discovery_() != nfc::STATUS_OK) { + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + return nfc::STATUS_FAILED; + } + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + + if (this->core_config_is_solo_ != core_config_should_be_solo) { + if (this->send_core_config_() != nfc::STATUS_OK) { + ESP_LOGV(TAG, "Failed to refresh core config"); + return nfc::STATUS_FAILED; + } + } + this->config_refresh_pending_ = false; + return nfc::STATUS_OK; +} + +uint8_t PN71xx::set_discover_map_() { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, + {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}); + tx.append(RF_DISCOVER_MAP_CONFIG); + + if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending discover map poll config"); + return nfc::STATUS_FAILED; + } + return nfc::STATUS_OK; +} + +uint8_t PN71xx::set_listen_mode_routing_() { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, + this->listen_mode_routing_config()); + + if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error setting listen mode routing config"); + return nfc::STATUS_FAILED; + } + return nfc::STATUS_OK; +} + +uint8_t PN71xx::start_discovery_() { + std::span rf_discovery_config = RF_DISCOVERY_CONFIG; + + if (!this->listening_enabled_) { + rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; + } else if (!this->polling_enabled_) { + rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, + {static_cast(rf_discovery_config.size())}); + for (const uint8_t mode_tech : rf_discovery_config) { + tx.append({mode_tech, 0x01}); // RF Technology and Mode will be executed in every discovery period + } + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + switch (rx.get_simple_status_response()) { + // in any of these cases, we are either already in or will remain in discovery, which satisfies the function call + case nfc::STATUS_OK: + case nfc::DISCOVERY_ALREADY_STARTED: + case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: + case nfc::DISCOVERY_TEAR_DOWN: + return nfc::STATUS_OK; + + default: + ESP_LOGE(TAG, "Error starting discovery"); + return nfc::STATUS_FAILED; + } + } + + return nfc::STATUS_OK; +} + +uint8_t PN71xx::stop_discovery_() { return this->deactivate_(nfc::DEACTIVATION_TYPE_IDLE, NFCC_TAG_WRITE_TIMEOUT); } + +uint8_t PN71xx::deactivate_(const uint8_t type, const uint16_t timeout) { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {type}); + + auto status = this->transceive_(tx, rx, timeout); + // if (status != nfc::STATUS_OK) { + // ESP_LOGE(TAG, "Error sending deactivate type %u", type); + // return nfc::STATUS_FAILED; + // } + return status; +} + +void PN71xx::select_endpoint_() { + if (this->discovered_endpoint_.empty()) { + ESP_LOGW(TAG, "No cached tags to select"); + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + return; + } + this->selecting_endpoint_ = 0; + for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { + if (!this->discovered_endpoint_[i].trig_called) { + this->selecting_endpoint_ = i; + break; + } + } + const auto &endpoint = this->discovered_endpoint_[this->selecting_endpoint_]; + // the RF interface must match the one set for this protocol in RF_DISCOVER_MAP_CONFIG + uint8_t interface = nfc::INTF_FRAME; + if (endpoint.protocol == nfc::PROT_ISODEP) { + interface = nfc::INTF_ISODEP; + } else if (endpoint.protocol == nfc::PROT_MIFARE) { + interface = nfc::INTF_TAGCMD; + } + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, + {endpoint.id, endpoint.protocol, interface}); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error selecting endpoint"); + } else { + this->nci_fsm_set_state_(NCIState::EP_SELECTING); + } +} + +uint8_t PN71xx::read_endpoint_data_(const uint8_t protocol, nfc::NfcTag &tag) { + switch (protocol) { + case nfc::PROT_MIFARE: + ESP_LOGV(TAG, "Reading Mifare classic"); + return this->read_mifare_classic_tag_(tag); + + case nfc::PROT_T2T: + ESP_LOGV(TAG, "Reading Mifare ultralight"); + return this->read_mifare_ultralight_tag_(tag); + + default: + ESP_LOGV(TAG, "Reading protocol 0x%02X is not supported", protocol); + break; + } + return nfc::STATUS_FAILED; +} + +uint8_t PN71xx::clean_endpoint_(const uint8_t protocol) { + switch (protocol) { + case nfc::PROT_MIFARE: + return this->format_mifare_classic_mifare_(); + + case nfc::PROT_T2T: + return this->clean_mifare_ultralight_(); + + default: + ESP_LOGE(TAG, "Unsupported tag for cleaning"); + break; + } + return nfc::STATUS_FAILED; +} + +uint8_t PN71xx::format_endpoint_(const uint8_t protocol) { + switch (protocol) { + case nfc::PROT_MIFARE: + return this->format_mifare_classic_ndef_(); + + case nfc::PROT_T2T: + return this->clean_mifare_ultralight_(); + + default: + ESP_LOGE(TAG, "Unsupported tag for formatting"); + break; + } + return nfc::STATUS_FAILED; +} + +uint8_t PN71xx::write_endpoint_(const uint8_t protocol, nfc::NfcTagUid &uid, + std::shared_ptr &message) { + switch (protocol) { + case nfc::PROT_MIFARE: + return this->write_mifare_classic_tag_(message); + + case nfc::PROT_T2T: + return this->write_mifare_ultralight_tag_(uid, message); + + default: + ESP_LOGE(TAG, "Unsupported tag for writing"); + break; + } + return nfc::STATUS_FAILED; +} + +bool PN71xx::parse_uid_(const uint8_t mode_tech, const std::span rf_tech_params, nfc::NfcTagUid &uid) { + if (mode_tech != (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA)) { + return false; + } + // RF technology parameters: SENS_RES (2 bytes), NFCID1 length, NFCID1, ... + if (rf_tech_params.size() < 3) { + ESP_LOGE(TAG, "NFC-A parameters too short"); + return false; + } + const uint8_t uid_length = rf_tech_params[2]; + if (uid_length == 0 || uid_length > nfc::NFC_UID_MAX_LENGTH || rf_tech_params.size() < 3u + uid_length) { + ESP_LOGE(TAG, "Invalid UID length: %u", uid_length); + return false; + } + uid.assign(rf_tech_params.begin() + 3, rf_tech_params.begin() + 3 + uid_length); + return true; +} + +std::unique_ptr PN71xx::build_tag_(const uint8_t protocol, const nfc::NfcTagUid &uid) { + if (protocol == nfc::PROT_MIFARE) { + return make_unique(uid, nfc::MIFARE_CLASSIC); + } + if (protocol == nfc::PROT_T2T) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + return make_unique(uid); +} + +size_t PN71xx::find_or_add_tag_(const uint8_t protocol, const nfc::NfcTagUid &uid) { + const auto tag_loc = this->find_tag_uid_(uid); + if (tag_loc.has_value()) { + ESP_LOGVV(TAG, "Tag cache updated"); + return tag_loc.value(); + } + if (this->discovered_endpoint_.size() >= MAX_DISCOVERED_ENDPOINTS) { + size_t oldest = 0; + for (size_t i = 1; i < this->discovered_endpoint_.size(); i++) { + if (this->discovered_endpoint_[i].last_seen < this->discovered_endpoint_[oldest].last_seen) { + oldest = i; + } + } + ESP_LOGW(TAG, "Tag cache full; dropping the tag seen longest ago"); + this->erase_tag_(oldest); + } + this->discovered_endpoint_.emplace_next() = DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), + .tag = this->build_tag_(protocol, uid), + .id = 0, + .protocol = protocol, + .trig_called = false}; + ESP_LOGVV(TAG, "Tag added to cache"); + return this->discovered_endpoint_.size() - 1; +} + +optional PN71xx::find_tag_uid_(const nfc::NfcTagUid &uid) { + if (!this->discovered_endpoint_.empty()) { + for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { + auto existing_tag_uid = this->discovered_endpoint_[i].tag->get_uid(); + bool uid_match = (uid.size() == existing_tag_uid.size()); + + if (uid_match) { + for (size_t i = 0; i < uid.size(); i++) { + uid_match &= (uid[i] == existing_tag_uid[i]); + } + if (uid_match) { + return i; + } + } + } + } + return nullopt; +} + +void PN71xx::purge_old_tags_() { + // millis(), not the loop start time: last_seen is stamped after tag operations that may block for seconds + const uint32_t now = millis(); + for (size_t i = this->discovered_endpoint_.size(); i > 0; i--) { + if (now - this->discovered_endpoint_[i - 1].last_seen > this->tag_ttl_) { + this->erase_tag_(i - 1); + } + } +} + +void PN71xx::erase_tag_(const uint8_t tag_index) { + if (tag_index < this->discovered_endpoint_.size()) { +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT + for (auto *trigger : this->triggers_ontagremoved_) { + trigger->process(this->discovered_endpoint_[tag_index].tag); + } +#endif +#ifdef NFC_TAG_LISTENER_COUNT + for (auto *listener : this->tag_listeners_) { + listener->tag_off(*this->discovered_endpoint_[tag_index].tag); + } +#endif + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); + // keep the remaining entries in order; selecting_endpoint_ indexes into this list + for (size_t i = tag_index; i + 1 < this->discovered_endpoint_.size(); i++) { + this->discovered_endpoint_[i] = std::move(this->discovered_endpoint_[i + 1]); + } + // StaticVector::resize() does not destroy the dropped slot; free its tag now, not when the slot is reused + this->discovered_endpoint_[this->discovered_endpoint_.size() - 1].tag.reset(); + this->discovered_endpoint_.resize(this->discovered_endpoint_.size() - 1); + } +} + +void PN71xx::nci_fsm_transition_() { + switch (this->nci_state_) { + case NCIState::NFCC_RESET: + if (this->reset_core_(true, true) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to reset NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); + return; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_INIT); + } + [[fallthrough]]; + + case NCIState::NFCC_INIT: + if (this->init_core_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to initialise NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); + return; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_CONFIG); + } + [[fallthrough]]; + + case NCIState::NFCC_CONFIG: + if (this->send_init_config_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to send initial config"); + this->nci_fsm_set_error_state_(NCIState::NFCC_CONFIG); + return; + } else { + this->config_refresh_pending_ = false; + this->nci_fsm_set_state_(NCIState::NFCC_SET_DISCOVER_MAP); + } + [[fallthrough]]; + + case NCIState::NFCC_SET_DISCOVER_MAP: + if (this->set_discover_map_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to set discover map"); + this->nci_fsm_set_error_state_(NCIState::NFCC_SET_DISCOVER_MAP); + return; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); + } + [[fallthrough]]; + + case NCIState::NFCC_SET_LISTEN_MODE_ROUTING: + if (this->set_listen_mode_routing_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to set listen mode routing"); + this->nci_fsm_set_error_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); + return; + } else { + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + [[fallthrough]]; + + case NCIState::RFST_IDLE: + if (this->nci_state_error_ == NCIState::RFST_DISCOVERY) { + this->stop_discovery_(); + } + + if (this->config_refresh_pending_) { + this->refresh_core_config_(); + } + + if (!this->listening_enabled_ && !this->polling_enabled_) { + return; + } + + if (this->start_discovery_() != nfc::STATUS_OK) { + ESP_LOGV(TAG, "Failed to start discovery"); + this->nci_fsm_set_error_state_(NCIState::RFST_DISCOVERY); + } else { + this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); + } + return; + + case NCIState::RFST_W4_HOST_SELECT: + select_endpoint_(); + [[fallthrough]]; + + // All cases below are waiting for NOTIFICATION messages + case NCIState::RFST_DISCOVERY: + if (this->config_refresh_pending_) { + this->refresh_core_config_(); + } + [[fallthrough]]; + + case NCIState::RFST_LISTEN_ACTIVE: + case NCIState::RFST_LISTEN_SLEEP: + case NCIState::RFST_POLL_ACTIVE: + case NCIState::EP_SELECTING: + case NCIState::EP_DEACTIVATING: + // only a notification from the NFCC ends the EP_ states; if it was lost, recover rather than wait forever. + // millis(), not the loop start time: the state is stamped after tag operations that may block for seconds. + if ((this->nci_state_ == NCIState::EP_SELECTING || this->nci_state_ == NCIState::EP_DEACTIVATING) && + !this->irq_pin_->digital_read() && millis() - this->last_nci_state_change_ > NFCC_STATE_TIMEOUT) { + ESP_LOGW(TAG, "Timed out waiting for notification in state %u; resetting NFCC", (uint8_t) this->nci_state_); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + return; + } + if (this->irq_pin_->digital_read()) { + this->process_message_(); + } + break; + + case NCIState::TEST: + case NCIState::FAILED: + case NCIState::NONE: + default: + return; + } +} + +void PN71xx::nci_fsm_set_state_(NCIState new_state) { + ESP_LOGVV(TAG, "nci_fsm_set_state_(%u)", (uint8_t) new_state); + this->nci_state_ = new_state; + this->nci_state_error_ = NCIState::NONE; + this->error_count_ = 0; + this->last_nci_state_change_ = millis(); +} + +bool PN71xx::nci_fsm_set_error_state_(NCIState new_state) { + ESP_LOGVV(TAG, "nci_fsm_set_error_state_(%u); error_count_ = %u", (uint8_t) new_state, this->error_count_); + this->nci_state_error_ = new_state; + if (this->error_count_++ > NFCC_MAX_ERROR_COUNT) { + if ((this->nci_state_error_ == NCIState::NFCC_RESET) || (this->nci_state_error_ == NCIState::NFCC_INIT) || + (this->nci_state_error_ == NCIState::NFCC_CONFIG)) { + ESP_LOGE(TAG, "Too many initialization failures -- check device connections"); + this->mark_failed(); + this->nci_fsm_set_state_(NCIState::FAILED); + } else { + ESP_LOGW(TAG, "Too many errors transitioning to state %u; resetting NFCC", (uint8_t) this->nci_state_error_); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + } + } + return this->error_count_ > NFCC_MAX_ERROR_COUNT; +} + +void PN71xx::process_message_() { + nfc::NciMessage rx; + if (this->read_nfcc(rx, NFCC_DEFAULT_TIMEOUT) != nfc::STATUS_OK) { + return; // No data + } + + switch (rx.get_message_type()) { + case nfc::NCI_PKT_MT_CTRL_NOTIFICATION: + if (rx.get_gid() == nfc::RF_GID) { + switch (rx.get_oid()) { + case nfc::RF_INTF_ACTIVATED_OID: + ESP_LOGVV(TAG, "RF_INTF_ACTIVATED_OID"); + this->process_rf_intf_activated_oid_(rx); + return; + + case nfc::RF_DISCOVER_OID: + ESP_LOGVV(TAG, "RF_DISCOVER_OID"); + this->process_rf_discover_oid_(rx); + return; + + case nfc::RF_DEACTIVATE_OID: + ESP_LOGVV(TAG, "RF_DEACTIVATE_OID: type: 0x%02X, reason: 0x%02X", rx.get_message()[3], rx.get_message()[4]); + this->process_rf_deactivate_oid_(rx); + return; + + default: + ESP_LOGV(TAG, "Unimplemented RF OID received: 0x%02X", rx.get_oid()); + } + } else if (rx.get_gid() == nfc::NCI_CORE_GID) { + switch (rx.get_oid()) { + case nfc::NCI_CORE_GENERIC_ERROR_OID: + ESP_LOGV(TAG, "NCI_CORE_GENERIC_ERROR_OID:"); + switch (rx.get_simple_status_response()) { + case nfc::DISCOVERY_ALREADY_STARTED: + ESP_LOGV(TAG, " DISCOVERY_ALREADY_STARTED"); + break; + + case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: + // Tag removed too soon + ESP_LOGV(TAG, " DISCOVERY_TARGET_ACTIVATION_FAILED"); + if (this->nci_state_ == NCIState::EP_SELECTING) { + this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); + if (!this->discovered_endpoint_.empty()) { + this->erase_tag_(this->selecting_endpoint_); + } + } else { + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + break; + + case nfc::DISCOVERY_TEAR_DOWN: + ESP_LOGV(TAG, " DISCOVERY_TEAR_DOWN"); + break; + + default: + ESP_LOGW(TAG, "Unknown error: 0x%02X", rx.get_simple_status_response()); + break; + } + break; + + default: + ESP_LOGV(TAG, "Unimplemented NCI Core OID received: 0x%02X", rx.get_oid()); + } + } else { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented notification: %s", nfc::format_bytes_to(buf, rx.get_message())); + } + break; + + case nfc::NCI_PKT_MT_CTRL_RESPONSE: { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented GID: 0x%02X OID: 0x%02X Full response: %s", rx.get_gid(), rx.get_oid(), + nfc::format_bytes_to(buf, rx.get_message())); + break; + } + + case nfc::NCI_PKT_MT_CTRL_COMMAND: { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented command: %s", nfc::format_bytes_to(buf, rx.get_message())); + break; + } + + case nfc::NCI_PKT_MT_DATA: + this->process_data_message_(rx); + break; + + default: { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented message type: %s", nfc::format_bytes_to(buf, rx.get_message())); + break; + } + } +} + +void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoint was activated + uint8_t discovery_id = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_DISCOVERY_ID); + uint8_t interface = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_INTERFACE); + uint8_t protocol = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_PROTOCOL); + uint8_t mode_tech = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MODE_TECH); + uint8_t max_size = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MAX_SIZE); + + ESP_LOGVV(TAG, "Endpoint activated -- interface: 0x%02X, protocol: 0x%02X, mode&tech: 0x%02X, max payload: %u", + interface, protocol, mode_tech, max_size); + + if (mode_tech & nfc::MODE_LISTEN_MASK) { + ESP_LOGVV(TAG, "Tag activated in listen mode"); + this->nci_fsm_set_state_(NCIState::RFST_LISTEN_ACTIVE); + return; + } + + this->nci_fsm_set_state_(NCIState::RFST_POLL_ACTIVE); + if (rx.get_message().size() < nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS) { + ESP_LOGE(TAG, "RF_INTF_ACTIVATED_NTF too short"); + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); + return; + } + nfc::NfcTagUid uid; + if (!this->parse_uid_(mode_tech, + std::span(rx.get_message()).subspan(nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS), + uid)) { + ESP_LOGE(TAG, "Could not build tag"); + } else { + auto &working_endpoint = this->discovered_endpoint_[this->find_or_add_tag_(protocol, uid)]; + working_endpoint.id = discovery_id; + working_endpoint.protocol = protocol; + working_endpoint.last_seen = App.get_loop_component_start_time(); + + switch (this->next_task_) { + case EP_CLEAN: + ESP_LOGD(TAG, " Tag cleaning"); + if (this->clean_endpoint_(working_endpoint.protocol) != nfc::STATUS_OK) { + ESP_LOGE(TAG, " Tag cleaning incomplete"); + } + ESP_LOGD(TAG, " Tag cleaned!"); + break; + + case EP_FORMAT: + ESP_LOGD(TAG, " Tag formatting"); + if (this->format_endpoint_(working_endpoint.protocol) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error formatting tag as NDEF"); + } + ESP_LOGD(TAG, " Tag formatted!"); + break; + + case EP_WRITE: + if (this->next_task_message_to_write_ != nullptr) { + ESP_LOGD(TAG, " Tag writing\n" + " Tag formatting"); + if (this->format_endpoint_(working_endpoint.protocol) != nfc::STATUS_OK) { + ESP_LOGE(TAG, " Tag could not be formatted for writing"); + } else { + ESP_LOGD(TAG, " Writing NDEF data"); + if (this->write_endpoint_(working_endpoint.protocol, working_endpoint.tag->get_uid(), + this->next_task_message_to_write_) != nfc::STATUS_OK) { + ESP_LOGE(TAG, " Failed to write message to tag"); + } + ESP_LOGD(TAG, " Finished writing NDEF data"); + this->next_task_message_to_write_ = nullptr; + this->on_finished_write_callback_.call(); + } + } + break; + + case EP_READ: + default: + if (!working_endpoint.trig_called) { + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + ESP_LOGI(TAG, "Read tag type %s with UID %s", working_endpoint.tag->get_tag_type().c_str(), + nfc::format_uid_to(uid_buf, working_endpoint.tag->get_uid())); + if (this->read_endpoint_data_(working_endpoint.protocol, *working_endpoint.tag) != nfc::STATUS_OK) { + ESP_LOGW(TAG, " Unable to read NDEF record(s)"); + } else if (working_endpoint.tag->has_ndef_message()) { + const auto &message = working_endpoint.tag->get_ndef_message(); + const auto &records = message->get_records(); + ESP_LOGD(TAG, " NDEF record(s):"); + for (const auto &record : records) { + ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); + } + } else { + ESP_LOGW(TAG, " No NDEF records found"); + } +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT + for (auto *trigger : this->triggers_ontag_) { + trigger->process(working_endpoint.tag); + } +#endif +#ifdef NFC_TAG_LISTENER_COUNT + for (auto *listener : this->tag_listeners_) { + listener->tag_on(*working_endpoint.tag); + } +#endif + working_endpoint.trig_called = true; + break; + } + } + // the tag was present for the whole operation, which may have taken longer than tag_ttl + working_endpoint.last_seen = millis(); + if (working_endpoint.protocol == nfc::PROT_MIFARE) { + this->halt_mifare_classic_tag_(); + } + } + if (this->next_task_ != EP_READ) { + this->read_mode(); + } + + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); +} + +void PN71xx::process_rf_discover_oid_(nfc::NciMessage &rx) { + if (rx.get_message().size() < nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS) { + ESP_LOGE(TAG, "RF_DISCOVER_NTF too short"); + return; + } + const uint8_t protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); + nfc::NfcTagUid uid; + if (!this->parse_uid_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), + std::span(rx.get_message()).subspan(nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS), uid)) { + ESP_LOGE(TAG, "Could not build tag!"); + } else { + auto &endpoint = this->discovered_endpoint_[this->find_or_add_tag_(protocol, uid)]; + endpoint.id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); + endpoint.protocol = protocol; + endpoint.last_seen = App.get_loop_component_start_time(); + } + + const auto &ntf = rx.get_message(); + if (ntf[ntf.size() - 1] != nfc::RF_DISCOVER_NTF_NT_MORE) { + this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); + ESP_LOGVV(TAG, "Discovered %zu endpoints", this->discovered_endpoint_.size()); + } +} + +void PN71xx::process_rf_deactivate_oid_(nfc::NciMessage &rx) { + this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; + + switch (rx.get_simple_status_response()) { + case nfc::DEACTIVATION_TYPE_DISCOVERY: + this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); + break; + + case nfc::DEACTIVATION_TYPE_IDLE: + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + break; + + case nfc::DEACTIVATION_TYPE_SLEEP: + case nfc::DEACTIVATION_TYPE_SLEEP_AF: + if (this->nci_state_ == NCIState::RFST_LISTEN_ACTIVE) { + this->nci_fsm_set_state_(NCIState::RFST_LISTEN_SLEEP); + } else if (this->nci_state_ == NCIState::RFST_POLL_ACTIVE) { + this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); + } else { + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + break; + + default: + break; + } +} + +void PN71xx::process_data_message_(nfc::NciMessage &rx) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); + + CardEmuResponse ndef_response; + this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); + + if (ndef_response.empty()) { + return; // no message returned, we cannot respond + } + + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, ndef_response); + ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); + if (this->transceive_(tx, rx, NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Sending reply for card emulation failed"); + } +} + +bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length, CardEmuResponse &ndef_response) { + const auto &ndef_message = this->card_emulation_ndef_; + // the NDEF file is a two-byte big-endian length (NLEN) followed by the message + const uint16_t ndef_msg_size = ndef_message.size(); + const uint32_t file_size = ndef_msg_size + 2; + // the reply must also hold the two status bytes; the CC's MLe keeps well-behaved readers below this + if (offset + static_cast(length) > file_size || + length + sizeof(CARD_EMU_T4T_OK) > CardEmuResponse::capacity()) { + return false; + } + for (uint32_t i = offset; i < offset + static_cast(length); i++) { + if (i == 0) { + ndef_response.push_back(ndef_msg_size >> 8); + } else if (i == 1) { + ndef_response.push_back(ndef_msg_size & 0xFF); + } else { + ndef_response.push_back(ndef_message[i - 2]); + } + } + if (offset + static_cast(length) == file_size) { + ESP_LOGD(TAG, "NDEF message sent"); + this->on_emulated_tag_scan_callback_.call(); + } + return true; +} + +void PN71xx::card_emu_t4t_get_response_(const std::span response, CardEmuResponse &ndef_response) { + ndef_response.clear(); + if (this->card_emulation_ndef_.empty()) { + ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); + return; + } + if (response.size() < nfc::NCI_PKT_HEADER_SIZE) { + return; + } + + const auto apdu_begin = response.begin() + nfc::NCI_PKT_HEADER_SIZE; + const size_t apdu_size = response.size() - nfc::NCI_PKT_HEADER_SIZE; + auto apdu_is = [&](const uint8_t *cmd, size_t cmd_size) { + return apdu_size == cmd_size && std::equal(apdu_begin, response.end(), cmd); + }; + auto apdu_starts_with = [&](const uint8_t *cmd, size_t cmd_size) { + return apdu_size >= cmd_size && std::equal(cmd, cmd + cmd_size, apdu_begin); + }; + auto append = [&](std::span bytes) { + for (const uint8_t byte : bytes) { + ndef_response.push_back(byte); + } + }; + bool ok = false; + + if (apdu_is(CARD_EMU_T4T_APP_SELECT, sizeof(CARD_EMU_T4T_APP_SELECT)) || + apdu_is(CARD_EMU_T4T_APP_SELECT, sizeof(CARD_EMU_T4T_APP_SELECT) - 1)) { // Le is optional + ESP_LOGVV(TAG, "CARD_EMU_NDEF_APP_SELECTED"); + this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_APP_SELECTED; + ok = true; + } else if (apdu_is(CARD_EMU_T4T_CC_SELECT, sizeof(CARD_EMU_T4T_CC_SELECT))) { + if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_APP_SELECTED) { + ESP_LOGVV(TAG, "CARD_EMU_CC_SELECTED"); + this->ce_state_ = CardEmulationState::CARD_EMU_CC_SELECTED; + ok = true; + } + } else if (apdu_is(CARD_EMU_T4T_NDEF_SELECT, sizeof(CARD_EMU_T4T_NDEF_SELECT))) { + ESP_LOGVV(TAG, "CARD_EMU_NDEF_SELECTED"); + this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_SELECTED; + ok = true; + } else if (apdu_starts_with(CARD_EMU_T4T_READ, sizeof(CARD_EMU_T4T_READ)) && apdu_size == 5) { + // READ BINARY: CLA INS P1 P2 Le, where P1-P2 is the offset + const uint16_t offset = (apdu_begin[2] << 8) | apdu_begin[3]; + const uint8_t length = apdu_begin[4]; + if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { + ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); + if (offset + static_cast(length) <= sizeof(CARD_EMU_T4T_CC)) { + append(std::span(CARD_EMU_T4T_CC).subspan(offset, length)); + ok = true; + } + } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { + ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED"); + ok = this->card_emu_t4t_read_ndef_(offset, length, ndef_response); + } + } else if (apdu_starts_with(CARD_EMU_T4T_WRITE, sizeof(CARD_EMU_T4T_WRITE)) && apdu_size >= 5) { + // UPDATE BINARY: CLA INS P1 P2 Lc data + const uint8_t length = apdu_begin[4]; + if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED && apdu_size >= 5u + length) { + ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); + char write_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, + nfc::format_bytes_to(write_buf, response.subspan(nfc::NCI_PKT_HEADER_SIZE + 5, length))); + ok = true; + } + } + + if (ok) { + append(CARD_EMU_T4T_OK); + } else { + ndef_response.clear(); + append(CARD_EMU_T4T_NOK); + this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; + } +} + +uint8_t PN71xx::transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, const uint16_t timeout, + const bool expect_notification) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + + // The NFCC may refuse a frame while it wakes from standby; the whole frame must then be sent again. + // A command must never be re-sent once it was accepted, as the NFCC will answer it (NCI 1.0, 3.2.1). + uint8_t attempts = 0; + while (this->write_nfcc(tx) != nfc::STATUS_OK) { + if (++attempts >= NFCC_MAX_COMM_FAILS) { + ESP_LOGE(TAG, "Error sending message"); + return nfc::STATUS_FAILED; + } + delay(NFCC_WRITE_RETRY_DELAY); + } + ESP_LOGVV(TAG, "Wrote: %s", nfc::format_bytes_to(buf, tx.get_message())); + + if (!tx.message_type_is(nfc::NCI_PKT_MT_DATA)) { + // Notifications may already be queued ahead of the response; skip them. They carry the same GID and OID + // as some responses (e.g. RF_DEACTIVATE_NTF), so the message type must be checked. A response to an earlier + // command whose read timed out (a tag leaving the field delays RF_DEACTIVATE_RSP) is skipped the same way. + for (uint8_t i = 0; i < NFCC_MAX_COMM_FAILS; i++) { + if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { + ESP_LOGW(TAG, "Error receiving response"); + return nfc::STATUS_FAILED; + } + ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); + if (rx.message_type_is(nfc::NCI_PKT_MT_CTRL_RESPONSE) && rx.get_gid() == tx.get_gid() && + rx.get_oid() == tx.get_oid()) { + break; + } + ESP_LOGW(TAG, "Discarding message received while waiting for response: %s", + nfc::format_bytes_to(buf, rx.get_message())); + } + // for commands, the GID and OID should match and the status should be OK + if (!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_RESPONSE) || (rx.get_gid() != tx.get_gid()) || + (rx.get_oid() != tx.get_oid())) { + ESP_LOGE(TAG, "Incorrect response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); + return nfc::STATUS_FAILED; + } + + if (!rx.simple_status_response_is(nfc::STATUS_OK)) { + ESP_LOGE(TAG, "Error in response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); + } + return rx.get_simple_status_response(); + } + + // when sending data to the endpoint, the first message is the credit notification from the NFCC + if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { + ESP_LOGW(TAG, "Error receiving credit notification"); + return nfc::STATUS_FAILED; + } + ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); + if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.gid_is(nfc::NCI_CORE_GID)) || + (!rx.oid_is(nfc::NCI_CORE_CONN_CREDITS_OID)) || (!rx.message_length_is(3))) { + ESP_LOGE(TAG, "Incorrect response to data message: %s", nfc::format_bytes_to(buf, rx.get_message())); + return nfc::STATUS_FAILED; + } + + if (expect_notification) { + // the endpoint's answer follows in a data message + if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error receiving data from endpoint"); + return nfc::STATUS_FAILED; + } + ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); + } + + return nfc::STATUS_OK; +} + +uint8_t PN71xx::wait_for_irq_(uint16_t timeout, bool pin_state) { + auto start_time = millis(); + + while (millis() - start_time < timeout) { + if (this->irq_pin_->digital_read() == pin_state) { + return nfc::STATUS_OK; + } + } + return nfc::STATUS_FAILED; +} + +} // namespace esphome::pn71xx diff --git a/esphome/components/pn71xx/pn71xx.h b/esphome/components/pn71xx/pn71xx.h new file mode 100644 index 00000000000..940d1874fc0 --- /dev/null +++ b/esphome/components/pn71xx/pn71xx.h @@ -0,0 +1,330 @@ +#pragma once + +#include "esphome/components/nfc/automation.h" +#include "esphome/components/nfc/nci_core.h" +#include "esphome/components/nfc/nci_message.h" +#include "esphome/components/nfc/nfc.h" +#include "esphome/components/nfc/nfc_helpers.h" +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +#include +#include +#include + +namespace esphome::pn71xx { + +// Time to wait for the NFCC to answer. NXP's reference stack waits 1 s for a response; 10 ms was short enough +// that a slow RF_DEACTIVATE_RSP, for example when a tag leaves the field, caused a full NFCC reset. +static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 100; +static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; +static constexpr uint16_t NFCC_TAG_WRITE_TIMEOUT = 100; +// Time to wait for IRQ to drop after a read; it drops within microseconds unless another message is queued +static constexpr uint16_t NFCC_IRQ_CLEAR_TIMEOUT = 5; +// Length of the VEN and DWL_REQ pulses when resetting the NFCC +static constexpr uint16_t NFCC_RESET_DELAY = 10; +// Time to wait before resending a frame the NFCC refused, e.g. while waking from standby +static constexpr uint16_t NFCC_WRITE_RETRY_DELAY = 5; +// Longest time the FSM may wait for a notification that ends a transitional state before resetting the NFCC +static constexpr uint32_t NFCC_STATE_TIMEOUT = 1000; + +static constexpr uint8_t NFCC_MAX_COMM_FAILS = 3; +static constexpr uint8_t NFCC_MAX_ERROR_COUNT = 10; + +static constexpr uint8_t XCHG_DATA_OID = 0x10; +static constexpr uint8_t MF_SECTORSEL_OID = 0x32; +static constexpr uint8_t MFC_AUTHENTICATE_OID = 0x40; +static constexpr uint8_t TEST_PRBS_OID = 0x30; +static constexpr uint8_t TEST_ANTENNA_OID = 0x3D; +static constexpr uint8_t TEST_GET_REGISTER_OID = 0x33; + +static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_A = 0x00; // key select A +static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_B = 0x80; // key select B +static constexpr uint8_t MFC_AUTHENTICATE_PARAM_EMBED_KEY = 0x10; + +static constexpr uint8_t CARD_EMU_T4T_APP_SELECT[] = {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, + 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}; +// MLe is 0xFD so a full-length READ BINARY response plus the two status bytes fits in one NCI data packet +static constexpr uint8_t CARD_EMU_T4T_CC[] = {0x00, 0x0F, 0x20, 0x00, 0xFD, 0x00, 0xFF, 0x04, + 0x06, 0xE1, 0x04, 0x00, 0xFF, 0x00, 0x00}; +// Largest NDEF message that fits in the emulated NDEF file (max file size in the CC, less the 2-byte length) +static constexpr uint16_t CARD_EMU_T4T_MAX_NDEF_SIZE = 0xFF - 2; +static constexpr uint8_t CARD_EMU_T4T_CC_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}; +static constexpr uint8_t CARD_EMU_T4T_NDEF_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}; +static constexpr uint8_t CARD_EMU_T4T_READ[] = {0x00, 0xB0}; +static constexpr uint8_t CARD_EMU_T4T_WRITE[] = {0x00, 0xD6}; +static constexpr uint8_t CARD_EMU_T4T_OK[] = {0x90, 0x00}; +static constexpr uint8_t CARD_EMU_T4T_NOK[] = {0x6A, 0x82}; + +static constexpr uint8_t CORE_CONFIG_SOLO[] = {0x01, // Number of parameter fields + 0x00, // config param identifier (TOTAL_DURATION) + 0x02, // length of value + 0x01, // TOTAL_DURATION (low)... + 0x00}; // TOTAL_DURATION (high): 1 ms + +static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter fields + 0x00, // config param identifier (TOTAL_DURATION) + 0x02, // length of value + 0xF8, // TOTAL_DURATION (low)... + 0x02}; // TOTAL_DURATION (high): 760 ms + +static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { + // poll modes + nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_FRAME, // poll mode + nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_FRAME, // poll mode + nfc::PROT_T3T, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_FRAME, // poll mode + nfc::PROT_ISODEP, nfc::RF_DISCOVER_MAP_MODE_POLL | nfc::RF_DISCOVER_MAP_MODE_LISTEN, + nfc::INTF_ISODEP, // poll & listen mode + nfc::PROT_MIFARE, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_TAGCMD}; // poll mode + +static constexpr uint8_t RF_DISCOVERY_LISTEN_CONFIG[] = { + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode + +static constexpr uint8_t RF_DISCOVERY_POLL_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF}; // poll mode + +static constexpr uint8_t RF_DISCOVERY_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF, // poll mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode + +enum class CardEmulationState : uint8_t { + CARD_EMU_IDLE, + CARD_EMU_NDEF_APP_SELECTED, + CARD_EMU_CC_SELECTED, + CARD_EMU_NDEF_SELECTED, + CARD_EMU_DESFIRE_PROD, +}; + +enum class NCIState : uint8_t { + NONE = 0x00, + NFCC_RESET, + NFCC_INIT, + NFCC_CONFIG, + NFCC_SET_DISCOVER_MAP, + NFCC_SET_LISTEN_MODE_ROUTING, + RFST_IDLE, + RFST_DISCOVERY, + RFST_W4_ALL_DISCOVERIES, + RFST_W4_HOST_SELECT, + RFST_LISTEN_ACTIVE, + RFST_LISTEN_SLEEP, + RFST_POLL_ACTIVE, + EP_DEACTIVATING, + EP_SELECTING, + TEST = 0xFE, + FAILED = 0xFF, +}; + +enum class TestMode : uint8_t { + TEST_NONE = 0x00, + TEST_PRBS, + TEST_ANTENNA, + TEST_GET_REGISTER, +}; + +/// A card emulation reply; the CC limits reads so every reply fits one NCI data packet +using CardEmuResponse = StaticVector; +/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads +using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) +static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; +/// Tags tracked at once. A device with a random UID looks like a new tag on every activation, but each entry +/// expires after tag_ttl, so a handful is enough; when the cache is full the entry seen longest ago is evicted. +static constexpr size_t MAX_DISCOVERED_ENDPOINTS = 8; + +struct DiscoveredEndpoint { + uint32_t last_seen; + std::unique_ptr tag; + uint8_t id; + uint8_t protocol; + bool trig_called; +}; + +/// Common driver for the NXP PN71xx family of NCI NFC controllers. The chip classes (PN7150, PN7160) supply the parts +/// that differ between chips; the bus classes supply read_nfcc() and write_nfcc(). +class PN71xx : public nfc::Nfcc, public Component { + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_irq_pin(GPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } + void set_ven_pin(GPIOPin *ven_pin) { this->ven_pin_ = ven_pin; } + + void set_tag_ttl(uint32_t ttl) { this->tag_ttl_ = ttl; } + void set_tag_emulation_message(const std::shared_ptr &message); + void set_tag_emulation_message(const std::string &message, bool include_android_app_record = true); + void set_tag_emulation_message(const char *message, bool include_android_app_record = true); + void set_tag_emulation_off(); + void set_tag_emulation_on(); + bool tag_emulation_enabled() { return this->listening_enabled_; } + + void set_polling_off(); + void set_polling_on(); + bool polling_enabled() { return this->polling_enabled_; } + +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT + void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } +#endif +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT + void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } +#endif + + template void add_on_emulated_tag_scan_callback(F &&callback) { + this->on_emulated_tag_scan_callback_.add(std::forward(callback)); + } + + template void add_on_finished_write_callback(F &&callback) { + this->on_finished_write_callback_.add(std::forward(callback)); + } + + bool is_writing() { return this->next_task_ != EP_READ; }; + + void read_mode(); + void clean_mode(); + void format_mode(); + void write_mode(); + void set_tag_write_message(std::shared_ptr message); + void set_tag_write_message(const std::string &message, bool include_android_app_record = true); + + uint8_t set_test_mode(TestMode test_mode, const std::vector &data, std::vector &result); + + protected: + uint8_t reset_core_(bool reset_config, bool power); + uint8_t init_core_(); + + /// Chip hooks + /// Called before the NFCC is reset, e.g. to make sure it will not start in firmware download mode + virtual void prepare_reset() {} + /// Validates what follows a successful CORE_RESET_RSP (in `rx`); may read more messages from the NFCC + virtual uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) = 0; + /// Logs chip information from a successful CORE_INIT_RSP + virtual uint8_t process_init_response(nfc::NciMessage &rx) = 0; + /// Parameters for the CORE_SET_CONFIG_CMD that configures the power management unit + virtual std::span pmu_config() const = 0; + /// Payload of the RF_SET_LISTEN_MODE_ROUTING_CMD + virtual std::span listen_mode_routing_config() const = 0; + + uint8_t send_init_config_(); + uint8_t send_core_config_(); + uint8_t refresh_core_config_(); + + uint8_t set_discover_map_(); + + uint8_t set_listen_mode_routing_(); + + uint8_t start_discovery_(); + uint8_t stop_discovery_(); + uint8_t deactivate_(uint8_t type, uint16_t timeout = NFCC_DEFAULT_TIMEOUT); + + void select_endpoint_(); + + uint8_t read_endpoint_data_(uint8_t protocol, nfc::NfcTag &tag); + uint8_t clean_endpoint_(uint8_t protocol); + uint8_t format_endpoint_(uint8_t protocol); + uint8_t write_endpoint_(uint8_t protocol, nfc::NfcTagUid &uid, std::shared_ptr &message); + + /// Reads the UID from the RF technology parameters of a discovery or activation notification + bool parse_uid_(uint8_t mode_tech, std::span rf_tech_params, nfc::NfcTagUid &uid); + std::unique_ptr build_tag_(uint8_t protocol, const nfc::NfcTagUid &uid); + /// Finds a cached endpoint by UID, or caches a new one, evicting the entry seen longest ago if the cache is full + size_t find_or_add_tag_(uint8_t protocol, const nfc::NfcTagUid &uid); + optional find_tag_uid_(const nfc::NfcTagUid &uid); + void purge_old_tags_(); + void erase_tag_(uint8_t tag_index); + + /// advance controller state as required + void nci_fsm_transition_(); + /// set new controller state + void nci_fsm_set_state_(NCIState new_state); + /// setting controller to this state caused an error; returns true if too many errors/failures + bool nci_fsm_set_error_state_(NCIState new_state); + /// parse & process incoming messages from the NFCC + void process_message_(); + void process_rf_intf_activated_oid_(nfc::NciMessage &rx); + void process_rf_discover_oid_(nfc::NciMessage &rx); + void process_rf_deactivate_oid_(nfc::NciMessage &rx); + void process_data_message_(nfc::NciMessage &rx); + + void card_emu_t4t_get_response_(std::span response, CardEmuResponse &ndef_response); + bool card_emu_t4t_read_ndef_(uint16_t offset, uint8_t length, CardEmuResponse &ndef_response); + + uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, + bool expect_notification = true); + virtual uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) = 0; + virtual uint8_t write_nfcc(nfc::NciMessage &tx) = 0; + + uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); + + uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); + uint8_t read_mifare_classic_block_(uint8_t block_num, std::array &data); + uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); + uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); + uint8_t sect_to_auth_(uint8_t block_num); + uint8_t format_mifare_classic_mifare_(); + uint8_t format_mifare_classic_ndef_(); + uint8_t write_mifare_classic_tag_(const std::shared_ptr &message); + uint8_t halt_mifare_classic_tag_(); + + uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); + uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + bool is_mifare_ultralight_formatted_(std::span page_3_to_6); + uint16_t read_mifare_ultralight_capacity_(); + uint8_t find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, + uint8_t &message_start_index); + uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); + uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); + uint8_t clean_mifare_ultralight_(); + + enum NfcTask : uint8_t { + EP_READ = 0, + EP_CLEAN, + EP_FORMAT, + EP_WRITE, + }; + + // members are ordered by alignment, widest first, to minimize padding + LazyCallbackManager on_emulated_tag_scan_callback_; + LazyCallbackManager on_finished_write_callback_; + + StaticVector discovered_endpoint_; + FixedVector card_emulation_ndef_; // encoded emulation message; empty when none is set +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT + StaticVector triggers_ontag_; +#endif +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT + StaticVector triggers_ontagremoved_; +#endif + std::shared_ptr next_task_message_to_write_; + + GPIOPin *irq_pin_{nullptr}; + GPIOPin *ven_pin_{nullptr}; + + uint32_t last_nci_state_change_{0}; + uint32_t tag_ttl_{250}; + + NfcTask next_task_{EP_READ}; + CardEmulationState ce_state_{CardEmulationState::CARD_EMU_IDLE}; + NCIState nci_state_{NCIState::NFCC_RESET}; + NCIState nci_state_error_{NCIState::NONE}; + uint8_t error_count_{0}; + uint8_t selecting_endpoint_{0}; + + bool config_refresh_pending_{false}; + bool core_config_is_solo_{false}; + bool listening_enabled_{false}; + bool polling_enabled_{true}; +}; + +} // namespace esphome::pn71xx diff --git a/esphome/components/pn7150/pn7150_mifare_classic.cpp b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp similarity index 80% rename from esphome/components/pn7150/pn7150_mifare_classic.cpp rename to esphome/components/pn71xx/pn71xx_mifare_classic.cpp index f1832d95f18..c1c927634d4 100644 --- a/esphome/components/pn7150/pn7150_mifare_classic.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp @@ -1,14 +1,16 @@ +#include #include +#include #include -#include "pn7150.h" +#include "pn71xx.h" #include "esphome/core/log.h" -namespace esphome::pn7150 { +namespace esphome::pn71xx { -static const char *const TAG = "pn7150.mifare_classic"; +static const char *const TAG = "pn71xx.mifare_classic"; -uint8_t PN7150::read_mifare_classic_tag_(nfc::NfcTag &tag) { +uint8_t PN71xx::read_mifare_classic_tag_(nfc::NfcTag &tag) { uint8_t current_block = 4; uint8_t message_start_index = 0; uint32_t message_length = 0; @@ -17,60 +19,63 @@ uint8_t PN7150::read_mifare_classic_tag_(nfc::NfcTag &tag) { ESP_LOGE(TAG, "Tag auth failed while attempting to read tag data"); return nfc::STATUS_FAILED; } - std::vector data; + std::array block_data; - if (this->read_mifare_classic_block_(current_block, data) == nfc::STATUS_OK) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data) == nfc::STATUS_OK) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return nfc::STATUS_FAILED; } } else { ESP_LOGE(TAG, "Failed to read block %u", current_block); return nfc::STATUS_FAILED; } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return nfc::STATUS_FAILED; + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return nfc::STATUS_FAILED; + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Block authentication failed for %u", current_block); return nfc::STATUS_FAILED; } } - std::vector block_data; if (this->read_mifare_classic_block_(current_block, block_data) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading block %u", current_block); return nfc::STATUS_FAILED; - } else { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); + } + for (const uint8_t byte : block_data) { + buffer.push_back(byte); } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return nfc::STATUS_FAILED; } - - tag.set_ndef_message(make_unique(buffer)); + tag.set_ndef_message(make_unique(std::span(buffer).subspan(message_start_index))); return nfc::STATUS_OK; } -uint8_t PN7150::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { +uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, + std::array &data) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGVV(TAG, "Read XCHG_DATA_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Timeout reading tag data"); @@ -84,32 +89,35 @@ uint8_t PN7150::read_mifare_classic_block_(uint8_t block_num, std::vectorsect_to_auth_(block_num), key_num}); - +uint8_t PN71xx::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key) { + uint8_t key_select = key_num; switch (key_num) { case nfc::MIFARE_CMD_AUTH_A: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_A; + key_select = MFC_AUTHENTICATE_PARAM_KS_A; break; case nfc::MIFARE_CMD_AUTH_B: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_B; + key_select = MFC_AUTHENTICATE_PARAM_KS_B; break; default: break; } - if (key != nullptr) { - tx.get_message().back() |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; - tx.get_message().insert(tx.get_message().end(), key, key + 6); + key_select |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {MFC_AUTHENTICATE_OID, this->sect_to_auth_(block_num), key_select}); + if (key != nullptr) { + tx.append(std::span(key, 6)); } char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -129,7 +137,7 @@ uint8_t PN7150::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, c return nfc::STATUS_OK; } -uint8_t PN7150::sect_to_auth_(const uint8_t block_num) { +uint8_t PN71xx::sect_to_auth_(const uint8_t block_num) { const uint8_t first_high_block = nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW * nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; if (block_num >= first_high_block) { return ((block_num - first_high_block) / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_HIGH) + @@ -138,7 +146,7 @@ uint8_t PN7150::sect_to_auth_(const uint8_t block_num) { return block_num / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW; } -uint8_t PN7150::format_mifare_classic_mifare_() { +uint8_t PN71xx::format_mifare_classic_mifare_() { static constexpr std::array BLANK_BUFFER = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::array TRAILER_BUFFER = { @@ -173,7 +181,7 @@ uint8_t PN7150::format_mifare_classic_mifare_() { return status; } -uint8_t PN7150::format_mifare_classic_ndef_() { +uint8_t PN71xx::format_mifare_classic_ndef_() { static constexpr std::array EMPTY_NDEF_MESSAGE = { 0x03, 0x03, 0xD0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::array BLANK_BLOCK = { @@ -237,11 +245,11 @@ uint8_t PN7150::format_mifare_classic_ndef_() { return status; } -uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { +uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_WRITE, block_num}); - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 1: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Sending XCHG_DATA_REQ failed"); @@ -249,7 +257,7 @@ uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da } // write command part two tx.set_payload({XCHG_DATA_OID}); - tx.get_message().insert(tx.get_message().end(), data, data + len); + tx.append(std::span(data, len)); ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 2: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { @@ -267,23 +275,11 @@ uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da return nfc::STATUS_OK; } -uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptr &message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); +uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptr &message) { + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -295,7 +291,7 @@ uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptrwrite_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE) != + if (this->write_mifare_classic_block_(current_block, &buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } @@ -310,7 +306,7 @@ uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptr #include #include #include -#include "pn7160.h" +#include "pn71xx.h" #include "esphome/core/log.h" -namespace esphome::pn7160 { +namespace esphome::pn71xx { -static const char *const TAG = "pn7160.mifare_ultralight"; +static const char *const TAG = "pn71xx.mifare_ultralight"; -uint8_t PN7160::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { - std::vector data; +uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data) != nfc::STATUS_OK) { @@ -42,33 +43,41 @@ uint8_t PN7160::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { return nfc::STATUS_FAILED; } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); - - tag.set_ndef_message(make_unique(data)); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return nfc::STATUS_FAILED; + } + tag.set_ndef_message(make_unique(std::span(data).subspan(skip))); return nfc::STATUS_OK; } -uint8_t PN7160::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { +uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); for (size_t i = 0; i * read_increment < num_bytes; i++) { - tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; - do { // loop because sometimes we struggle here...???... + const uint8_t page = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; + tx.set_payload({nfc::MIFARE_CMD_READ, page}); + // a short answer (e.g. a NAK for a page beyond the end of the tag) is retried a limited number of times + uint8_t attempts = 0; + do { if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading tag data"); return nfc::STATUS_FAILED; } + if (++attempts > NFCC_MAX_COMM_FAILS && rx.get_payload_size() < read_increment) { + ESP_LOGE(TAG, "Short read from page %u", page); + return nfc::STATUS_FAILED; + } } while (rx.get_payload_size() < read_increment); - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 - : rx.get_message().end() - (bytes_offset - num_bytes + 1); - - if ((pages_in_end_itr > rx.get_message().begin()) && (pages_in_end_itr < rx.get_message().end())) { - data.insert(data.end(), rx.get_message().begin() + nfc::NCI_PKT_HEADER_SIZE, pages_in_end_itr); + // the payload ends with a status byte; keep only the bytes still wanted from this read + const uint16_t wanted = num_bytes - i * read_increment; + const size_t count = std::min(read_increment, wanted); + for (const uint8_t byte : rx.get_payload().subspan(0, count)) { + data.push_back(byte); } } @@ -78,7 +87,7 @@ uint8_t PN7160::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_OK; } -bool PN7160::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN71xx::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -86,8 +95,8 @@ bool PN7160::is_mifare_ultralight_formatted_(const std::vector &page_3_ (page_3_to_6[p4_offset + 2] != 0xFF) || (page_3_to_6[p4_offset + 3] != 0xFF)); } -uint16_t PN7160::read_mifare_ultralight_capacity_() { - std::vector data; +uint16_t PN71xx::read_mifare_ultralight_capacity_() { + UltralightReadBuffer data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; @@ -95,7 +104,7 @@ uint16_t PN7160::read_mifare_ultralight_capacity_() { return 0; } -uint8_t PN7160::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -115,36 +124,25 @@ uint8_t PN7160::find_mifare_ultralight_ndef_(const std::vector &page_3_ return nfc::STATUS_FAILED; } -uint8_t PN7160::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { +uint8_t PN71xx::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return nfc::STATUS_FAILED; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != + if (this->write_mifare_ultralight_page_(current_page, &buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } @@ -154,7 +152,7 @@ uint8_t PN7160::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::sha return nfc::STATUS_OK; } -uint8_t PN7160::clean_mifare_ultralight_() { +uint8_t PN71xx::clean_mifare_ultralight_() { uint32_t capacity = this->read_mifare_ultralight_capacity_(); uint8_t pages = (capacity / nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) + nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; @@ -168,12 +166,10 @@ uint8_t PN7160::clean_mifare_ultralight_() { return nfc::STATUS_OK; } -uint8_t PN7160::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; - payload.insert(payload.end(), write_data, write_data + len); - +uint8_t PN71xx::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, payload); + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}); + tx.append(std::span(write_data, len)); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error writing page %u", page_num); @@ -182,4 +178,4 @@ uint8_t PN7160::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *w return nfc::STATUS_OK; } -} // namespace esphome::pn7160 +} // namespace esphome::pn71xx diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index 8a809672db4..5092c321479 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -9,6 +9,9 @@ namespace esphome::preferences { class IntervalSyncer final : public PollingComponent { public: + // User provided, not "= default": `new(p) IntervalSyncer()` would zero-fill .bss that is already zero. + IntervalSyncer() {} + // Remove before 2027.3.0 ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0") void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 0412d8a8422..00c2e043102 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -144,14 +144,8 @@ void PrometheusHandler::add_friendly_name_label_(AsyncResponseStream *stream, st } } -#ifdef USE_ESP8266 -void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, - EntityBase *obj, std::string &area, std::string &node, - std::string &friendly_name) { -#else -void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, ProgmemStr metric_name, EntityBase *obj, std::string &area, std::string &node, std::string &friendly_name) { -#endif stream->print(metric_name); stream->print(ESPHOME_F("{id=\"")); stream->print(relabel_id_(obj).c_str()); @@ -328,7 +322,7 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat // State print_metric_labels_(stream, ESPHOME_F("esphome_light_state"), obj, area, node, friendly_name); stream->print(ESPHOME_F("\"} ")); - stream->print(obj->remote_values.is_on()); + stream->print(obj->get_reported_values().is_on()); stream->print(ESPHOME_F("\n")); // Brightness and RGBW light::LightColorValues color = obj->current_values; @@ -903,11 +897,7 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); -#ifdef USE_STORE_LOG_STR_IN_FLASH - stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); -#else - stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); -#endif + stream->print(reinterpret_cast(valve::valve_operation_to_str(obj->current_operation))); stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); @@ -947,7 +937,11 @@ void PrometheusHandler::climate_setting_row_(AsyncResponseStream *stream, climat stream->print(ESPHOME_F("\",category=\"")); stream->print(setting.c_str()); stream->print(ESPHOME_F("\",setting_value=\"")); +#ifdef USE_ESP8266 + stream->print((const __FlashStringHelper *) setting_value); +#else stream->print(LOG_STR_ARG(setting_value)); +#endif stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index bc256c68854..9dcbf7e6869 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -6,8 +6,9 @@ #include "esphome/components/web_server_base/web_server_base.h" #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" +#include "esphome/core/progmem.h" #ifdef USE_CLIMATE #include "esphome/core/log.h" #endif @@ -68,13 +69,8 @@ class PrometheusHandler final : public AsyncWebHandler, public Component { void add_node_label_(AsyncResponseStream *stream, std::string &node); void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name); /// Print metric name and common labels (id, area, node, friendly_name, name) -#ifdef USE_ESP8266 - void print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, EntityBase *obj, - std::string &area, std::string &node, std::string &friendly_name); -#else - void print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, std::string &area, + void print_metric_labels_(AsyncResponseStream *stream, ProgmemStr metric_name, EntityBase *obj, std::string &area, std::string &node, std::string &friendly_name); -#endif #ifdef USE_SENSOR /// Return the type for prometheus diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h deleted file mode 100644 index 380ef023049..00000000000 --- a/esphome/components/pulse_counter/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/pulse_counter/pulse_counter_sensor.h" - -namespace esphome::pulse_counter { - -template class SetTotalPulsesAction final : public Action { - public: - SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} - - TEMPLATABLE_VALUE(uint32_t, total_pulses) - - void play(const Ts &...x) override { this->pulse_counter_->set_total_pulses(this->total_pulses_.value(x...)); } - - protected: - PulseCounterSensor *pulse_counter_; -}; - -} // namespace esphome::pulse_counter diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 7c5a0590d75..677cfb6a5e1 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -21,8 +21,7 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType CONF_USE_PCNT = "use_pcnt" @@ -41,10 +40,6 @@ PulseCounterSensor = pulse_counter_ns.class_( "PulseCounterSensor", sensor.Sensor, cg.PollingComponent ) -SetTotalPulsesAction = pulse_counter_ns.class_( - "SetTotalPulsesAction", automation.Action -) - def validate_internal_filter(value: ConfigType) -> ConfigType: use_pcnt = value.get(CONF_USE_PCNT) @@ -150,25 +145,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_total_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "pulse_counter.set_total_pulses", - SetTotalPulsesAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PulseCounterSensor), cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_total_pulses", cg.uint32), ) -async def set_total_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) - cg.add(var.set_total_pulses(template_)) - return var diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h deleted file mode 100644 index 885922a22ac..00000000000 --- a/esphome/components/pulse_meter/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/pulse_meter/pulse_meter_sensor.h" - -namespace esphome::pulse_meter { - -template class SetTotalPulsesAction final : public Action { - public: - SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} - - TEMPLATABLE_VALUE(uint32_t, total_pulses) - - void play(const Ts &...x) override { this->pulse_meter_->set_total_pulses(this->total_pulses_.value(x...)); } - - protected: - PulseMeterSensor *pulse_meter_; -}; - -} // namespace esphome::pulse_meter diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 9bda891efc8..2df1ba96e97 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -19,8 +19,7 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE, ID, TimePeriodMicroseconds -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, TimePeriodMicroseconds from esphome.types import ConfigType CODEOWNERS = ["@stevebaxter", "@cstaahl", "@TrentHouliston"] @@ -38,8 +37,6 @@ FILTER_MODES = { "PULSE": PulseMeterInternalFilterMode.FILTER_PULSE, } -SetTotalPulsesAction = pulse_meter_ns.class_("SetTotalPulsesAction", automation.Action) - def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: return cv.positive_time_period_microseconds(value) @@ -100,25 +97,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_total_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "pulse_meter.set_total_pulses", - SetTotalPulsesAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PulseMeterSensor), cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_total_pulses", cg.uint32), ) -async def set_total_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) - cg.add(var.set_total_pulses(template_)) - return var diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index ad13bdcca77..e4a7781538d 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -85,18 +85,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_BATTERY_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_VOLTAGE]) - cg.add(var.set_battery_voltage(sens)) - if CONF_SIGNAL_STRENGTH in config: - sens = await sensor.new_sensor(config[CONF_SIGNAL_STRENGTH]) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index bf5e31b48ab..2b19cf84985 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -69,19 +69,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) diff --git a/esphome/components/pzem6l24/__init__.py b/esphome/components/pzem6l24/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp new file mode 100644 index 00000000000..f9f97606a82 --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -0,0 +1,335 @@ +#include "pzem6l24.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::pzem6l24 { + +static const char *const TAG = "pzem6l24"; + +// ----------------------------------------------------------------------- +// Register map (input registers, starting address 0x0000): +// +// The PZEM-6L24 returns all register bytes in little-endian order, +// i.e. the low byte of each 16-bit register is transmitted first. +// 32-bit quantities occupy two consecutive registers with the low +// word at the lower address. +// +// NOTE: this is the opposite of standard Modbus, and of the single-phase +// pzemac component, which decodes big-endian. It is not an oversight: the +// byte order below was established against a live PZEM-6L24, so please do +// not "correct" it to big-endian without a device to verify against. +// +// Byte offset = register_address × 2 +// +// 0x0000 (byte 0) – Voltage A (uint16, ×0.1 V) +// 0x0001 (byte 2) – Voltage B (uint16, ×0.1 V) +// 0x0002 (byte 4) – Voltage C (uint16, ×0.1 V) +// 0x0003 (byte 6) – Current A (uint16, ×0.01 A) +// 0x0004 (byte 8) – Current B (uint16, ×0.01 A) +// 0x0005 (byte 10) – Current C (uint16, ×0.01 A) +// 0x0006 (byte 12) – Frequency A (uint16, ×0.01 Hz) +// 0x0007 (byte 14) – Frequency B (uint16, ×0.01 Hz) +// 0x0008 (byte 16) – Frequency C (uint16, ×0.01 Hz) +// 0x0009 (byte 18) – Voltage angle B (uint16, ×0.01 °) +// 0x000A (byte 20) – Voltage angle C (uint16, ×0.01 °) +// 0x000B (byte 22) – Current angle A (uint16, ×0.01 °) +// 0x000C (byte 24) – Current angle B (uint16, ×0.01 °) +// 0x000D (byte 26) – Current angle C (uint16, ×0.01 °) +// 0x000E (byte 28) – Active power A (int32 lo-word, ×0.1 W) +// 0x000F (byte 30) – Active power A (int32 hi-word) +// 0x0010 (byte 32) – Active power B (int32 lo-word, ×0.1 W) +// 0x0011 (byte 34) – Active power B (int32 hi-word) +// 0x0012 (byte 36) – Active power C (int32 lo-word, ×0.1 W) +// 0x0013 (byte 38) – Active power C (int32 hi-word) +// 0x0014 (byte 40) – Reactive power A (int32 lo-word, ×0.1 var) +// 0x0015 (byte 42) – Reactive power A (int32 hi-word) +// 0x0016 (byte 44) – Reactive power B (int32 lo-word, ×0.1 var) +// 0x0017 (byte 46) – Reactive power B (int32 hi-word) +// 0x0018 (byte 48) – Reactive power C (int32 lo-word, ×0.1 var) +// 0x0019 (byte 50) – Reactive power C (int32 hi-word) +// 0x001A (byte 52) – Apparent power A (int32 lo-word, ×0.1 VA) +// 0x001B (byte 54) – Apparent power A (int32 hi-word) +// 0x001C (byte 56) – Apparent power B (int32 lo-word, ×0.1 VA) +// 0x001D (byte 58) – Apparent power B (int32 hi-word) +// 0x001E (byte 60) – Apparent power C (int32 lo-word, ×0.1 VA) +// 0x001F (byte 62) – Apparent power C (int32 hi-word) +// 0x0020 (byte 64) – Total active pwr (int32 lo-word, ×0.1 W) +// 0x0021 (byte 66) – Total active pwr (int32 hi-word) +// 0x0022 (byte 68) – Total reactive pwr (int32 lo-word, ×0.1 var) +// 0x0023 (byte 70) – Total reactive pwr (int32 hi-word) +// 0x0024 (byte 72) – Total apparent pwr (int32 lo-word, ×0.1 VA) +// 0x0025 (byte 74) – Total apparent pwr (int32 hi-word) +// 0x0026 (byte 76) – Power factor A/B: hi-byte = A (×0.01), lo-byte = B (×0.01) +// 0x0027 (byte 78) – Power factor C/tot:hi-byte = C (×0.01), lo-byte = total (×0.01) +// 0x0028 (byte 80) – Active energy A (uint32 lo-word, ×0.1 kWh) +// 0x0029 (byte 82) – Active energy A (uint32 hi-word) +// 0x002A (byte 84) – Active energy B (uint32 lo-word, ×0.1 kWh) +// 0x002B (byte 86) – Active energy B (uint32 hi-word) +// 0x002C (byte 88) – Active energy C (uint32 lo-word, ×0.1 kWh) +// 0x002D (byte 90) – Active energy C (uint32 hi-word) +// 0x002E (byte 92) – Reactive energy A (uint32 lo-word, ×0.1 kvarh) +// 0x002F (byte 94) – Reactive energy A (uint32 hi-word) +// 0x0030 (byte 96) – Reactive energy B (uint32 lo-word, ×0.1 kvarh) +// 0x0031 (byte 98) – Reactive energy B (uint32 hi-word) +// 0x0032 (byte 100)– Reactive energy C (uint32 lo-word, ×0.1 kvarh) +// 0x0033 (byte 102)– Reactive energy C (uint32 hi-word) +// 0x0034 (byte 104)– Apparent energy A (uint32 lo-word, ×0.1 kVAh) +// 0x0035 (byte 106)– Apparent energy A (uint32 hi-word) +// 0x0036 (byte 108)– Apparent energy B (uint32 lo-word, ×0.1 kVAh) +// 0x0037 (byte 110)– Apparent energy B (uint32 hi-word) +// 0x0038 (byte 112)– Apparent energy C (uint32 lo-word, ×0.1 kVAh) +// 0x0039 (byte 114)– Apparent energy C (uint32 hi-word) +// 0x003A (byte 116)– Total active nrg (uint32 lo-word, ×0.1 kWh) +// 0x003B (byte 118)– Total active nrg (uint32 hi-word) +// 0x003C (byte 120)– Total reactive nrg (uint32 lo-word, ×0.1 kvarh) +// 0x003D (byte 122)– Total reactive nrg (uint32 hi-word) +// 0x003E (byte 124)– Total apparent nrg (uint32 lo-word, ×0.1 kVAh) +// 0x003F (byte 126)– Total apparent nrg (uint32 hi-word) +// ----------------------------------------------------------------------- + +// Width of a quantity in the register map above. +enum RegType : uint8_t { + REG_U8, // single byte (the packed power factors) + REG_U16, // one register, unsigned + REG_U32, // two registers, unsigned, low word first + REG_I32, // two registers, signed, low word first +}; + +// Scale factor of a quantity; the table has only these two, so a byte replaces a float per entry. +enum Scale : uint8_t { + SCALE_DECI, // ×0.1 + SCALE_CENTI, // ×0.01 +}; + +// One decodable quantity: where it lives in the payload, how to read it and which sensor it feeds. +// Copied out of flash with memcpy, so it must stay trivially copyable. +struct SensorEntry { + sensor::Sensor *PZEM6L24::*member; + uint8_t offset; + RegType type; + Scale scale; +}; +static_assert(std::is_trivially_copyable_v, "SENSORS is copied out of flash with memcpy"); + +// True for the periodic register read issued by update(); the only other request is the 0x42 reset. +static bool is_register_read(std::span request_pdu) { + return modbus::helpers::pdu_function_code(request_pdu) == + static_cast(modbus::FunctionCode::READ_INPUT_REGISTERS); +} + +void PZEM6L24::on_response(std::span request_pdu, std::span response_pdu) { + if (!is_register_read(request_pdu)) { + return; + } + this->read_finished_(); + const auto data = modbus::helpers::server_pdu_payload(response_pdu); + // Anything but exactly PZEM_PAYLOAD_SIZE bytes cannot be decoded by the table. + if (data.size() != PZEM_PAYLOAD_SIZE) { + ESP_LOGW(TAG, "Invalid data size for PZEM-6L24: expected %zu bytes, got %zu", PZEM_PAYLOAD_SIZE, data.size()); + this->read_failed_(); + return; + } + this->consecutive_failures_ = 0; + this->publish_(data.data()); +} + +void PZEM6L24::on_error(std::span request_pdu, modbus::ExceptionCode) { + this->request_failed_(request_pdu); +} + +bool PZEM6L24::on_no_response(std::span request_pdu) { + this->request_failed_(request_pdu); + return false; // no retry; the next update() polls again. +} + +void PZEM6L24::on_not_sent(std::span request_pdu) { this->request_failed_(request_pdu); } + +// A register read that produced no measurements counts toward blanking; a failed energy reset does +// not, but the user is told. The hub has already logged the cause. +void PZEM6L24::request_failed_(std::span request_pdu) { + if (is_register_read(request_pdu)) { + this->read_finished_(); + this->read_failed_(); + } else { + ESP_LOGW(TAG, "Energy reset failed; the counters were not cleared"); + } +} + +// `data` points at PZEM_PAYLOAD_SIZE validated bytes, or is nullptr to blank every sensor; both walk +// the same table. +void PZEM6L24::publish_(const uint8_t *data) { + const bool available = data != nullptr; + + // Byte offset, width and scale for every quantity, in register-map order. All three phases share the + // same grid frequency, so phase A's register is reported. + // + // PROGMEM: on ESP8266 .rodata is DRAM, so the table would otherwise cost ~280 bytes of RAM; the price + // is one 8-byte copy per sensor per poll. No name column for the same reason; dump_config() names + // every sensor from flash. + static constexpr SensorEntry SENSORS[] PROGMEM = { + // Voltages (×0.1 V) + {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + // Currents (×0.01 A) + {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + // Frequency (×0.01 Hz) + {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + // Active powers (×0.1 W, signed) + {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + // Reactive powers (×0.1 var, signed) + {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + // Apparent powers (×0.1 VA, signed) + {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + // Power factors (×0.01), packed two per register: + // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A + // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C + {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + // Active energies (×0.1 kWh, unsigned) + {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + // Reactive energies (×0.1 kvarh, unsigned) + {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + // Apparent energies (×0.1 kVAh, unsigned) + {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + }; + + for (const SensorEntry &flash_entry : SENSORS) { + SensorEntry entry; + progmem_memcpy(&entry, &flash_entry, sizeof(entry)); + sensor::Sensor *sens = this->*entry.member; + if (sens == nullptr) + continue; + if (!available) { + sens->publish_state(NAN); + continue; + } + // No default: an added RegType must fail to compile. The wire is little-endian, hence the reversed + // byte arguments. + const size_t o = entry.offset; + float raw = 0.0f; + switch (entry.type) { + case REG_U8: + raw = data[o]; + break; + case REG_U16: + raw = encode_uint16(data[o + 1], data[o]); + break; + case REG_U32: + raw = encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o]); + break; + case REG_I32: + raw = static_cast(encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o])); + break; + } + sens->publish_state(raw * (entry.scale == SCALE_CENTI ? 0.01f : 0.1f)); + } +} + +void PZEM6L24::update() { + if (this->read_input_registers(0x0000, PZEM_REGISTER_COUNT)) { + this->reads_outstanding_++; + } else if (this->reads_outstanding_ == 0) { + // Refused with nothing in flight: no callback is coming, and the hub has logged why. A refusal + // while a read is outstanding is a duplicate of it, which still resolves in that read's callback. + this->read_failed_(); + } +} + +void PZEM6L24::dump_config() { + ESP_LOGCONFIG(TAG, + "PZEM-6L24:\n" + " Address: 0x%02X", + this->address_); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_); + LOG_SENSOR(" ", "Current A", this->current_a_); + LOG_SENSOR(" ", "Current B", this->current_b_); + LOG_SENSOR(" ", "Current C", this->current_c_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); + LOG_SENSOR(" ", "Frequency", this->frequency_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); +} + +// Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. +void PZEM6L24::read_failed_() { + if (this->consecutive_failures_ >= MAX_CONSECUTIVE_READ_FAILURES) { + return; + } + if (++this->consecutive_failures_ == MAX_CONSECUTIVE_READ_FAILURES) { + ESP_LOGW(TAG, "No valid reading in %u consecutive polls; the readings are now unavailable", + MAX_CONSECUTIVE_READ_FAILURES); + this->publish_(nullptr); + } +} + +// One terminal has arrived for a register read, so that read is no longer in flight. +void PZEM6L24::read_finished_() { + if (this->reads_outstanding_ > 0) { + this->reads_outstanding_--; + } +} + +void PZEM6L24::reset_energy(ResetPhase phase_option) { + const auto pdu = build_reset_pdu(phase_option); + // A refused request gets no callback, so report it here. + if (!this->queue_pdu(pdu)) { + this->request_failed_(pdu); + } +} + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h new file mode 100644 index 00000000000..bf13f25053e --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -0,0 +1,161 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/modbus/modbus.h" + +#include +#include + +namespace esphome::pzem6l24 { + +// Options for the reset_energy action +enum ResetPhase : uint8_t { + RESET_PHASE_A = 0x00, // Reset phase A energy only + RESET_PHASE_B = 0x01, // Reset phase B energy only + RESET_PHASE_C = 0x02, // Reset phase C energy only + RESET_PHASE_COMBINED = 0x03, // Reset combined (sum) energy only + RESET_PHASE_ALL = 0x0F, // Reset all energy counters +}; + +// Reset energy function code (PZEM-6L24 specific, non-standard Modbus) +static constexpr uint8_t PZEM_CMD_RESET_ENERGY = 0x42; + +// Input registers read per poll (0x0000 - 0x003F inclusive) and the payload size that yields. +static constexpr uint8_t PZEM_REGISTER_COUNT = 64; +static constexpr size_t PZEM_PAYLOAD_SIZE = PZEM_REGISTER_COUNT * 2; + +// Failed polls tolerated before the readings are blanked; one collision on a shared RS-485 bus should +// not take every entity unavailable for a whole update interval. +static constexpr uint8_t MAX_CONSECUTIVE_READ_FAILURES = 3; + +// The energy reset command as it goes on the wire: function code, reserved byte, phase selector. The +// hub adds the address and CRC. Split out so the phase byte can be pinned by a test. +constexpr std::array build_reset_pdu(ResetPhase phase) { + return {PZEM_CMD_RESET_ENERGY, 0x00, static_cast(phase)}; +} + +class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + public: + // Per-phase sensors; each setter is named after its config key + void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } + void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } + void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } + + void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } + void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } + void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } + + void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } + void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } + void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } + + void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } + void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } + void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } + + void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } + void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } + void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } + + void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } + void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } + void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } + + void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } + void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } + void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } + + void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } + void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } + void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } + + void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } + void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } + void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } + + // Combined sensors + void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } + void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } + void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } + void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } + void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } + void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } + void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } + void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + + // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. + void reset_energy(ResetPhase phase_option); + + void update() override; + + void on_response(std::span request_pdu, std::span response_pdu) override; + + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + + bool on_no_response(std::span request_pdu) override; + + void on_not_sent(std::span request_pdu) override; + + void dump_config() override; + + protected: + void publish_(const uint8_t *data); + void request_failed_(std::span request_pdu); + void read_failed_(); + void read_finished_(); + + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + + // Per-phase sensors + sensor::Sensor *voltage_a_{nullptr}; + sensor::Sensor *voltage_b_{nullptr}; + sensor::Sensor *voltage_c_{nullptr}; + + sensor::Sensor *current_a_{nullptr}; + sensor::Sensor *current_b_{nullptr}; + sensor::Sensor *current_c_{nullptr}; + + sensor::Sensor *active_power_a_{nullptr}; + sensor::Sensor *active_power_b_{nullptr}; + sensor::Sensor *active_power_c_{nullptr}; + + sensor::Sensor *reactive_power_a_{nullptr}; + sensor::Sensor *reactive_power_b_{nullptr}; + sensor::Sensor *reactive_power_c_{nullptr}; + + sensor::Sensor *apparent_power_a_{nullptr}; + sensor::Sensor *apparent_power_b_{nullptr}; + sensor::Sensor *apparent_power_c_{nullptr}; + + sensor::Sensor *power_factor_a_{nullptr}; + sensor::Sensor *power_factor_b_{nullptr}; + sensor::Sensor *power_factor_c_{nullptr}; + + sensor::Sensor *active_energy_a_{nullptr}; + sensor::Sensor *active_energy_b_{nullptr}; + sensor::Sensor *active_energy_c_{nullptr}; + + sensor::Sensor *reactive_energy_a_{nullptr}; + sensor::Sensor *reactive_energy_b_{nullptr}; + sensor::Sensor *reactive_energy_c_{nullptr}; + + sensor::Sensor *apparent_energy_a_{nullptr}; + sensor::Sensor *apparent_energy_b_{nullptr}; + sensor::Sensor *apparent_energy_c_{nullptr}; + + // Combined sensors + sensor::Sensor *frequency_{nullptr}; + sensor::Sensor *total_active_power_{nullptr}; + sensor::Sensor *total_reactive_power_{nullptr}; + sensor::Sensor *total_apparent_power_{nullptr}; + sensor::Sensor *total_power_factor_{nullptr}; + sensor::Sensor *total_active_energy_{nullptr}; + sensor::Sensor *total_reactive_energy_{nullptr}; + sensor::Sensor *total_apparent_energy_{nullptr}; +}; + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py new file mode 100644 index 00000000000..fd0430d85be --- /dev/null +++ b/esphome/components/pzem6l24/sensor.py @@ -0,0 +1,198 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import modbus, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FREQUENCY, + CONF_ID, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_REACTIVE_POWER, + DEVICE_CLASS_VOLTAGE, + ICON_CURRENT_AC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE, + UNIT_WATT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["modbus"] +CODEOWNERS = ["@nuttytree"] + +pzem6l24_ns = cg.esphome_ns.namespace("pzem6l24") +PZEM6L24 = pzem6l24_ns.class_( + "PZEM6L24", cg.PollingComponent, modbus.ModbusClientDevice +) + +ResetPhase = pzem6l24_ns.enum("ResetPhase") +RESET_PHASE_OPTIONS = { + "all": ResetPhase.RESET_PHASE_ALL, + "a": ResetPhase.RESET_PHASE_A, + "b": ResetPhase.RESET_PHASE_B, + "c": ResetPhase.RESET_PHASE_C, + "combined": ResetPhase.RESET_PHASE_COMBINED, +} + +# Combined config keys +CONF_TOTAL_ACTIVE_POWER = "total_active_power" +CONF_TOTAL_REACTIVE_POWER = "total_reactive_power" +CONF_TOTAL_APPARENT_POWER = "total_apparent_power" +CONF_TOTAL_POWER_FACTOR = "total_power_factor" +CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" +CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" +CONF_TOTAL_APPARENT_ENERGY = "total_apparent_energy" +CONF_PHASE = "phase" + + +_VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +_CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_REACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_APPARENT_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_POWER_FACTOR_SCHEMA = sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_REACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_APPARENT_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + icon=ICON_CURRENT_AC, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Quantities measured once per phase; the config key is "_". +_PHASE_QUANTITIES = { + "voltage": _VOLTAGE_SCHEMA, + "current": _CURRENT_SCHEMA, + "active_power": _ACTIVE_POWER_SCHEMA, + "reactive_power": _REACTIVE_POWER_SCHEMA, + "apparent_power": _APPARENT_POWER_SCHEMA, + "power_factor": _POWER_FACTOR_SCHEMA, + "active_energy": _ACTIVE_ENERGY_SCHEMA, + "reactive_energy": _REACTIVE_ENERGY_SCHEMA, + "apparent_energy": _APPARENT_ENERGY_SCHEMA, +} + +# Quantities the meter reports once, keyed by config key. +_COMBINED_QUANTITIES = { + CONF_FREQUENCY: _FREQUENCY_SCHEMA, + CONF_TOTAL_ACTIVE_POWER: _ACTIVE_POWER_SCHEMA, + CONF_TOTAL_REACTIVE_POWER: _REACTIVE_POWER_SCHEMA, + CONF_TOTAL_APPARENT_POWER: _APPARENT_POWER_SCHEMA, + CONF_TOTAL_POWER_FACTOR: _POWER_FACTOR_SCHEMA, + CONF_TOTAL_ACTIVE_ENERGY: _ACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_REACTIVE_ENERGY: _REACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, +} + +# Every sensor by config key; the C++ setter is "set__sensor". +_SENSORS = { + f"{name}_{phase}": schema + for name, schema in _PHASE_QUANTITIES.items() + for phase in "abc" +} | _COMBINED_QUANTITIES + +# The meter answers unit addresses 1 to 247; 0 is the Modbus broadcast address and 248 to 255 are reserved. +_ADDRESS_SCHEMA = cv.All( + cv.hex_uint8_t, + cv.Range(min=1, max=247, msg="The PZEM-6L24 answers unit addresses 1 to 247 only"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PZEM6L24), + **{cv.Optional(key): schema for key, schema in _SENSORS.items()}, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(modbus.modbus_device_schema(0x01)) + .extend({cv.Optional(CONF_ADDRESS, default=0x01): _ADDRESS_SCHEMA}) +) + + +automation.register_apply_action( + "pzem6l24.reset_energy", + maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PZEM6L24), + cv.Optional(CONF_PHASE, default="all"): cv.enum( + RESET_PHASE_OPTIONS, lower=True + ), + } + ), + automation.ApplyField(CONF_PHASE, "reset_energy", ResetPhase), +) + + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="client") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_client_device(var, config) + + for key in _SENSORS: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 409de911249..c0d163c1dec 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { LOG_SENSOR("", "Power Factor", this->power_factor_sensor_); } -void PZEMAC::reset_energy_() { +void PZEMAC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; this->queue_pdu(pdu); } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 723b21e0b05..5eb712d4cc8 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" @@ -9,8 +8,6 @@ namespace esphome::pzemac { -template class ResetEnergyAction; - class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } @@ -28,27 +25,15 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice modbus::ResponseStatus status) override; void dump_config() override; + void reset_energy(); protected: - template friend class ResetEnergyAction; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; sensor::Sensor *energy_sensor_{nullptr}; sensor::Sensor *frequency_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; - - void reset_energy_(); -}; - -template class ResetEnergyAction final : public Action { - public: - ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} - - void play(const Ts &...x) override { this->pzemac_->reset_energy_(); } - - protected: - PZEMAC *pzemac_; }; } // namespace esphome::pzemac diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index f093262e18b..90e0abb14c1 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -35,9 +33,6 @@ AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) -# Actions -ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -85,24 +80,15 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_apply_action( "pzemac.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PZEMAC), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) -async def reset_energy_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config: ConfigType) -> None: @@ -117,27 +103,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) - if CONF_FREQUENCY in config: - conf = config[CONF_FREQUENCY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_frequency_sensor(sens)) - if CONF_POWER_FACTOR in config: - conf = config[CONF_POWER_FACTOR] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 69c8a9dd6c2..954fbae9783 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" @@ -34,14 +33,4 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction final : public Action { - public: - ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} - - void play(const Ts &...x) override { this->pzemdc_->reset_energy(); } - - protected: - PZEMDC *pzemdc_; -}; - } // namespace esphome::pzemdc diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b9f7246b721..b3f59720ad3 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,8 +20,6 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -29,9 +27,6 @@ AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) -# Actions -ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -67,24 +62,15 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_apply_action( "pzemdc.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.GenerateID(CONF_ID): cv.use_id(PZEMDC), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) -async def reset_energy_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config: ConfigType) -> None: @@ -99,19 +85,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index e0186be1638..06091c4f1ca 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -150,21 +150,12 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(config[CONF_DATA_RATE])) cg.add(var.set_range(config[CONF_RANGE])) - if CONF_FIELD_STRENGTH_X in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X]) - cg.add(var.set_x_sensor(sens)) - if CONF_FIELD_STRENGTH_Y in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y]) - cg.add(var.set_y_sensor(sens)) - if CONF_FIELD_STRENGTH_Z in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z]) - cg.add(var.set_z_sensor(sens)) - if CONF_HEADING in config: - sens = await sensor.new_sensor(config[CONF_HEADING]) - cg.add(var.set_heading_sensor(sens)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) + await sensors(CONF_HEADING, var.set_heading_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) if CONF_DRDY_PIN in config: pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) cg.add(var.set_drdy_pin(pin)) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 19b8549f75a..befef872f0a 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,6 +1,11 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -40,11 +45,14 @@ from esphome.const import ( CONF_ZERO, ) from esphome.core import ID, coroutine +from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] + CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" @@ -90,9 +98,42 @@ REMOTE_TRANSMITTABLE_SCHEMA = cv.Schema( ) -async def register_listener(var, config): +# Listener and dumper lists are StaticVectors sized from these counts, so every registration +# must go through add_listener / add_dumper. Every receiver's list gets the same capacity, so +# the slots are keyed by receiver and the define is the largest count any one receiver needs. +LISTENER_COUNT_DEFINE = "REMOTE_BASE_LISTENER_COUNT" +DUMPER_COUNT_DEFINE = "REMOTE_BASE_DUMPER_COUNT" + + +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) +_request_dumper_slot = cg.slot_counter(DUMPER_COUNT_DEFINE) + + +def add_listener(receiver: MockObj, listener: MockObj) -> None: + _request_listener_slot(str(receiver)) + cg.add(receiver.register_listener(listener)) + + +def add_dumper(receiver: MockObj, dumper: MockObj) -> None: + _request_dumper_slot(str(receiver)) + cg.add(receiver.register_dumper(dumper)) + + +async def register_listener(var: MockObj, config: ConfigType) -> None: receiver = await cg.get_variable(config[CONF_RECEIVER_ID]) - cg.add(receiver.register_listener(var)) + add_listener(receiver, var) + + +async def attach_receiver( + var: MockObj, config: ConfigType, key: str = CONF_RECEIVER_ID +) -> None: + """Link the configured receiver to an entity and register the entity as its listener. + + The C++ set_receiver() no longer registers the listener; the slot for it is counted here. + """ + receiver = await cg.get_variable(config[key]) + cg.add(var.set_receiver(receiver)) + add_listener(receiver, var) async def register_transmittable(var, config): @@ -100,8 +141,59 @@ async def register_transmittable(var, config): cg.add(var.set_transmitter(transmitter_)) -def register_binary_sensor(name, type, schema): - return BINARY_SENSOR_REGISTRY.register(name, type, schema) +# Registry names that share a protocol source file +def _protocol_stem(name: str) -> str: + if name.startswith("rc_switch"): + return "rc_switch" + if name == "canalsatld": + return "canalsat" + return name + + +def protocol_define(name: str) -> str: + return f"USE_REMOTE_PROTOCOL_{_protocol_stem(name).upper()}" + + +_PROTOCOL_STEMS = sorted( + path.name.removesuffix("_protocol.cpp") + for path in Path(__file__).parent.glob("*_protocol.cpp") +) + + +def request_protocol(name: str) -> None: + """Keep a protocol's source file in the build; components using it from C++ must call this.""" + if _protocol_stem(name) not in _PROTOCOL_STEMS: + raise ValueError( + f"Unknown remote protocol {name!r}; expected one of {', '.join(_PROTOCOL_STEMS)}" + ) + cg.add_define(protocol_define(name)) + + +def _request_protocol_if_in_tree(name: str) -> None: + """Registry names from external components have no source file here and need no define.""" + if _protocol_stem(name) in _PROTOCOL_STEMS: + request_protocol(name) + + +# Only the protocol sources a configuration uses are compiled +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} +) + + +def register_binary_sensor( + name: str, type: MockObj, schema: cv.Schema | dict +) -> Callable[[Callable[[MockObj, ConfigType], Any]], Callable]: + registerer = BINARY_SENSOR_REGISTRY.register(name, type, schema) + + def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: + async def new_func(var: MockObj, config: ConfigType) -> None: + _request_protocol_if_in_tree(name) + await coroutine(func)(var, config) + + return registerer(new_func) + + return decorator def register_trigger(name, type, data_type): @@ -114,6 +206,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -131,6 +224,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -171,6 +265,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: @@ -213,7 +308,13 @@ DUMPER_REGISTRY = Registry() def validate_dumpers(value): if isinstance(value, str) and value.lower() == "all": return validate_dumpers(list(DUMPER_REGISTRY.keys())) - return cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + entries = cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + # a dumper listed twice would register twice; the receiver holds one secondary dumper + return list( + { + next(k for k in entry if k in DUMPER_REGISTRY): entry for entry in entries + }.values() + ) def validate_triggers(base_schema): @@ -1048,7 +1149,7 @@ def gobox_dumper(var, config): @register_action("gobox", GoboxAction, GOBOX_SCHEMA) async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.int_) + template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) cg.add(var.set_code(template_)) @@ -1439,7 +1540,7 @@ def validate_rc_switch_raw_code(value): def build_rc_switch_protocol(config): if isinstance(config, int): - return rc_switch_protocols[config] + return rc_switch_protocol(config) pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1526,7 +1627,7 @@ RC_SWITCH_TRANSMITTER = cv.Schema( } ) -rc_switch_protocols = ns.RC_SWITCH_PROTOCOLS +rc_switch_protocol = ns.rc_switch_protocol RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) @@ -2005,6 +2106,61 @@ async def aeha_action(var, config, args): cg.add(var.set_carrier_frequency(templ)) +# Hob2Hood +( + Hob2HoodData, + Hob2HoodBinarySensor, + Hob2HoodTrigger, + Hob2HoodAction, + Hob2HoodDumper, +) = declare_protocol("Hob2Hood") + +Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand") +HOB2HOOD_COMMAND_OPTIONS = { + "light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF, + "light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON, + "fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF, + "fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW, + "fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM, + "fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH, + "fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX, +} + +HOB2HOOD_SCHEMA = cv.Schema( + {cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)} +) + + +@register_binary_sensor("hob2hood", Hob2HoodBinarySensor, HOB2HOOD_SCHEMA) +def hob2hood_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + Hob2HoodData, + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("hob2hood", Hob2HoodTrigger, Hob2HoodData) +def hob2hood_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("hob2hood", Hob2HoodDumper) +def hob2hood_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA) +async def hob2hood_action( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: + template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand) + cg.add(var.set_command(template_)) + + # Haier HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( "Haier" diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 7ff32923bef..a309c124eed 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -191,9 +191,9 @@ class ABBWelcomeData { class ABBWelcomeProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ABBWelcomeData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const ABBWelcomeData &data) override; + void encode(RemoteTransmitData *dst, const ABBWelcomeData &src); + optional decode(RemoteReceiveData src); + void dump(const ABBWelcomeData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t data) const; diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 3f4e98bd438..98a55011552 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,9 +15,9 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const AEHAData &data) override; + void encode(RemoteTransmitData *dst, const AEHAData &data); + optional decode(RemoteReceiveData src); + void dump(const AEHAData &data); private: std::string format_data_(const std::vector &data); diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index 30b99dbeb77..ed9d6aa6712 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -16,9 +16,9 @@ struct Beo4Data { class Beo4Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Beo4Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Beo4Data &data) override; + void encode(RemoteTransmitData *dst, const Beo4Data &data); + optional decode(RemoteReceiveData src); + void dump(const Beo4Data &data); }; DECLARE_REMOTE_PROTOCOL(Beo4) diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index 1d5b6217147..bfea463b7d4 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -13,9 +13,9 @@ struct BrennenstuhlData { class BrennenstuhlProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const BrennenstuhlData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const BrennenstuhlData &data) override; + void encode(RemoteTransmitData *dst, const BrennenstuhlData &data); + optional decode(RemoteReceiveData src); + void dump(const BrennenstuhlData &data); }; DECLARE_REMOTE_PROTOCOL(Brennenstuhl) diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index 674fa99ea10..c71390c267c 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -21,9 +21,9 @@ struct ByronSXData { class ByronSXProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ByronSXData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ByronSXData &data) override; + void encode(RemoteTransmitData *dst, const ByronSXData &data); + optional decode(RemoteReceiveData src); + void dump(const ByronSXData &data); }; DECLARE_REMOTE_PROTOCOL(ByronSX) diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 5ba9115ea86..09bead18b3c 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -19,9 +19,9 @@ struct CanalSatLDData : public CanalSatData {}; class CanalSatBaseProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CanalSatData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const CanalSatData &data) override; + void encode(RemoteTransmitData *dst, const CanalSatData &data); + optional decode(RemoteReceiveData src); + void dump(const CanalSatData &data); protected: uint16_t frequency_; diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index d9441e84178..29a306ce291 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -21,9 +21,9 @@ struct CoolixData { class CoolixProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CoolixData &data) override; - optional decode(RemoteReceiveData data) override; - void dump(const CoolixData &data) override; + void encode(RemoteTransmitData *dst, const CoolixData &data); + optional decode(RemoteReceiveData data); + void dump(const CoolixData &data); }; DECLARE_REMOTE_PROTOCOL(Coolix) diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index c89f4e78e11..f319b55f432 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -13,9 +13,9 @@ struct DishData { class DishProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DishData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DishData &data) override; + void encode(RemoteTransmitData *dst, const DishData &data); + optional decode(RemoteReceiveData src); + void dump(const DishData &data); }; DECLARE_REMOTE_PROTOCOL(Dish) diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 148c7c17bc8..954c3cf1d38 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -20,9 +20,9 @@ struct DooyaData { class DooyaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DooyaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DooyaData &data) override; + void encode(RemoteTransmitData *dst, const DooyaData &data); + optional decode(RemoteReceiveData src); + void dump(const DooyaData &data); }; DECLARE_REMOTE_PROTOCOL(Dooya) diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 693a1bbe85b..4e879f0f75b 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -19,9 +19,9 @@ struct DraytonData { class DraytonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DraytonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DraytonData &data) override; + void encode(RemoteTransmitData *dst, const DraytonData &data); + optional decode(RemoteReceiveData src); + void dump(const DraytonData &data); }; DECLARE_REMOTE_PROTOCOL(Drayton) diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 3473a489b2c..663e50fb4b5 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -21,9 +21,9 @@ struct DysonData { class DysonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DysonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DysonData &data) override; + void encode(RemoteTransmitData *dst, const DysonData &data); + optional decode(RemoteReceiveData src); + void dump(const DysonData &data); }; DECLARE_REMOTE_PROTOCOL(Dyson) diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index f6b278771e0..0c8797af70c 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -31,9 +31,9 @@ class GoboxProtocol : public RemoteProtocol { void dump_timings_(const RawTimings &timings) const; public: - void encode(RemoteTransmitData *dst, const GoboxData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const GoboxData &data) override; + void encode(RemoteTransmitData *dst, const GoboxData &data); + optional decode(RemoteReceiveData src); + void dump(const GoboxData &data); }; DECLARE_REMOTE_PROTOCOL(Gobox) diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 9c45ba1a635..e1fd60411fc 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,9 +13,9 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const HaierData &data) override; + void encode(RemoteTransmitData *dst, const HaierData &data); + optional decode(RemoteReceiveData src); + void dump(const HaierData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp new file mode 100644 index 00000000000..9e706b9fe66 --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.cpp @@ -0,0 +1,83 @@ +#include "hob2hood_protocol.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::remote_base { + +static const char *const TAG = "remote.hob2hood"; + +// A frame is 25 bits: a leading 0, then the command byte, command + 1 and command + 2. Zero bits are marks +// and one bits are spaces; equal neighbours merge into one run of n * BIT_TIME_US plus a fixed adjustment. +static constexpr uint8_t NBITS = 25; +static constexpr uint32_t BIT_TIME_US = 700; +static constexpr int32_t MARK_ADJUST_US = 300; +static constexpr int32_t SPACE_ADJUST_US = -200; +// The longest frame (light_off) has 18 runs +static constexpr uint8_t MAX_RUNS = 18; + +static constexpr std::array COMMANDS = { + HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW, + HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX, +}; +// Same order as COMMANDS; the last entry is the fallback +PROGMEM_STRING_TABLE(Hob2HoodCommandNames, "light_off", "light_on", "fan_off", "fan_low", "fan_medium", "fan_high", + "fan_max", "unknown"); + +// Walks the frame of `command` as runs of equal bits. emit(is_mark, length_us, is_last) returns false to stop. +template static bool walk_runs(Hob2HoodCommand command, F &&emit) { + // Shifted so the first of the 25 bits is the top bit + uint32_t bits = ((uint32_t(command) << 16) | (uint32_t(uint8_t(command + 1)) << 8) | uint8_t(command + 2)) + << (32 - NBITS); + int8_t run = 0; + for (uint8_t i = 0; i < NBITS; i++, bits <<= 1) { + const bool bit = (bits & 0x80000000) != 0; + run += bit ? 1 : -1; + const bool last = i == NBITS - 1; + if (last || (((bits << 1) & 0x80000000) != 0) != bit) { + const uint32_t length = BIT_TIME_US * std::abs(run) + (run < 0 ? MARK_ADJUST_US : SPACE_ADJUST_US); + if (!emit(run < 0, length, last)) + return false; + run = 0; + } + } + return true; +} + +void Hob2HoodProtocol::encode(RemoteTransmitData *dst, const Hob2HoodData &data) { + dst->set_carrier_frequency(38000); + dst->reserve(MAX_RUNS); + walk_runs(data.command, [dst](bool is_mark, uint32_t length, bool) { + if (is_mark) { + dst->mark(length); + } else { + dst->space(length); + } + return true; + }); +} + +optional Hob2HoodProtocol::decode(RemoteReceiveData src) { + for (auto command : COMMANDS) { + src.reset(); + // The receiver does not capture a trailing space, so the last run only has to match when it is a mark + const bool matched = walk_runs(command, [&src](bool is_mark, uint32_t length, bool last) { + return is_mark ? src.expect_mark(length) : (last || src.expect_space(length)); + }); + if (matched) + return Hob2HoodData{command}; + } + return {}; +} + +void Hob2HoodProtocol::dump(const Hob2HoodData &data) { + uint8_t index = 0; + while (index < COMMANDS.size() && COMMANDS[index] != data.command) + index++; + ESP_LOGI(TAG, "Received Hob2Hood: %s", + LOG_STR_ARG(Hob2HoodCommandNames::get_log_str(index, Hob2HoodCommandNames::LAST_INDEX))); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h new file mode 100644 index 00000000000..44bd873a65b --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +enum Hob2HoodCommand : uint8_t { + HOB2HOOD_COMMAND_LIGHT_OFF = 0xd5, + HOB2HOOD_COMMAND_LIGHT_ON = 0xd2, + HOB2HOOD_COMMAND_FAN_OFF = 0xd8, + HOB2HOOD_COMMAND_FAN_LOW = 0x6c, + HOB2HOOD_COMMAND_FAN_MEDIUM = 0x6f, + HOB2HOOD_COMMAND_FAN_HIGH = 0xe1, + HOB2HOOD_COMMAND_FAN_MAX = 0x72, +}; + +struct Hob2HoodData { + Hob2HoodCommand command; + bool operator==(const Hob2HoodData &rhs) const { return this->command == rhs.command; } +}; + +class Hob2HoodProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const Hob2HoodData &data); + optional decode(RemoteReceiveData src); + void dump(const Hob2HoodData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Hob2Hood) + +template class Hob2HoodAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(Hob2HoodCommand, command) + void encode(RemoteTransmitData *dst, Ts... x) override { + Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); + } +}; + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index f6e2548dead..5911664fc39 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -14,9 +14,9 @@ struct JVCData { class JVCProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const JVCData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const JVCData &data) override; + void encode(RemoteTransmitData *dst, const JVCData &data); + optional decode(RemoteReceiveData src); + void dump(const JVCData &data); }; DECLARE_REMOTE_PROTOCOL(JVC) diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 432313b87b2..335fbd164b1 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -24,9 +24,9 @@ struct KeeloqData { class KeeloqProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const KeeloqData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const KeeloqData &data) override; + void encode(RemoteTransmitData *dst, const KeeloqData &data); + optional decode(RemoteReceiveData src); + void dump(const KeeloqData &data); }; DECLARE_REMOTE_PROTOCOL(Keeloq) diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 97159749956..91dfbadb0c2 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -16,9 +16,9 @@ struct LGData { class LGProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const LGData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const LGData &data) override; + void encode(RemoteTransmitData *dst, const LGData &data); + optional decode(RemoteReceiveData src); + void dump(const LGData &data); }; DECLARE_REMOTE_PROTOCOL(LG) diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index 18662ec7598..f0d2410fe27 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -27,9 +27,9 @@ struct MagiQuestData { class MagiQuestProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MagiQuestData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MagiQuestData &data) override; + void encode(RemoteTransmitData *dst, const MagiQuestData &data); + optional decode(RemoteReceiveData src); + void dump(const MagiQuestData &data); }; DECLARE_REMOTE_PROTOCOL(MagiQuest) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 47bad6826fc..85bbef1cb1f 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -67,9 +67,9 @@ class MideaData { class MideaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MideaData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const MideaData &data) override; + void encode(RemoteTransmitData *dst, const MideaData &src); + optional decode(RemoteReceiveData src); + void dump(const MideaData &data); }; DECLARE_REMOTE_PROTOCOL(Midea) diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index c967e72f134..a37fb93f4fd 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,9 +13,9 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MirageData &data) override; + void encode(RemoteTransmitData *dst, const MirageData &data); + optional decode(RemoteReceiveData src); + void dump(const MirageData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 7b310e8ba5b..1337f7a8b32 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -14,9 +14,9 @@ struct NECData { class NECProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const NECData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NECData &data) override; + void encode(RemoteTransmitData *dst, const NECData &data); + optional decode(RemoteReceiveData src); + void dump(const NECData &data); }; DECLARE_REMOTE_PROTOCOL(NEC) diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebcd2a2c113..ebf85387b04 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -24,9 +24,9 @@ class NexaProtocol : public RemoteProtocol { void zero(RemoteTransmitData *dst) const; void sync(RemoteTransmitData *dst) const; - void encode(RemoteTransmitData *dst, const NexaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NexaData &data) override; + void encode(RemoteTransmitData *dst, const NexaData &data); + optional decode(RemoteReceiveData src); + void dump(const NexaData &data); }; DECLARE_REMOTE_PROTOCOL(Nexa) diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index d13c0f27985..84df3c08b72 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -16,9 +16,9 @@ struct PanasonicData { class PanasonicProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PanasonicData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PanasonicData &data) override; + void encode(RemoteTransmitData *dst, const PanasonicData &data); + optional decode(RemoteReceiveData src); + void dump(const PanasonicData &data); }; DECLARE_REMOTE_PROTOCOL(Panasonic) diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index 514ab675016..d02bd3451f2 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -13,9 +13,9 @@ struct PioneerData { class PioneerProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PioneerData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PioneerData &data) override; + void encode(RemoteTransmitData *dst, const PioneerData &data); + optional decode(RemoteReceiveData src); + void dump(const PioneerData &data); }; DECLARE_REMOTE_PROTOCOL(Pioneer) diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index f4f6b2144d9..bfd04c5cd9a 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -30,9 +30,9 @@ class ProntoProtocol : public RemoteProtocol { std::string compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase); public: - void encode(RemoteTransmitData *dst, const ProntoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ProntoData &data) override; + void encode(RemoteTransmitData *dst, const ProntoData &data); + optional decode(RemoteReceiveData src); + void dump(const ProntoData &data); }; DECLARE_REMOTE_PROTOCOL(Pronto) diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index dbb89e41c60..f6f0f33c6e2 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -14,9 +14,9 @@ struct RC5Data { class RC5Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC5Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC5Data &data) override; + void encode(RemoteTransmitData *dst, const RC5Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC5Data &data); }; DECLARE_REMOTE_PROTOCOL(RC5) diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index fda9d98ecbb..c4a2e8529bb 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -15,9 +15,9 @@ struct RC6Data { class RC6Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC6Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC6Data &data) override; + void encode(RemoteTransmitData *dst, const RC6Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC6Data &data); }; DECLARE_REMOTE_PROTOCOL(RC6) diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index 612558ca1c9..de16c55cb02 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -1,29 +1,21 @@ #include "rc_switch_protocol.h" + +#include +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; -const RCSwitchBase RC_SWITCH_PROTOCOLS[9] = {RCSwitchBase(0, 0, 0, 0, 0, 0, false), - RCSwitchBase(350, 10850, 350, 1050, 1050, 350, false), - RCSwitchBase(650, 6500, 650, 1300, 1300, 650, false), - RCSwitchBase(3000, 7100, 400, 1100, 900, 600, false), - RCSwitchBase(380, 2280, 380, 1140, 1140, 380, false), - RCSwitchBase(3000, 7000, 500, 1000, 1000, 500, false), - RCSwitchBase(10350, 450, 450, 900, 900, 450, true), - RCSwitchBase(300, 9300, 150, 900, 900, 150, false), - RCSwitchBase(250, 2500, 250, 1250, 250, 250, false)}; - -RCSwitchBase::RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, - uint32_t one_high, uint32_t one_low, bool inverted) - : sync_high_(sync_high), - sync_low_(sync_low), - zero_high_(zero_high), - zero_low_(zero_low), - one_high_(one_high), - one_low_(one_low), - inverted_(inverted) {} +RCSwitchBase rc_switch_protocol(uint8_t index) { + RCSwitchBase protocol; + // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing + if (index >= std::size(RC_SWITCH_PROTOCOLS)) + index = 0; + progmem_memcpy(&protocol, &RC_SWITCH_PROTOCOLS[index], sizeof(protocol)); + return protocol; +} void RCSwitchBase::one(RemoteTransmitData *dst) const { if (!this->inverted_) { @@ -133,11 +125,11 @@ bool RCSwitchBase::decode(RemoteReceiveData &src, uint64_t *out_data, uint8_t *o optional RCSwitchBase::decode(RemoteReceiveData &src) const { RCSwitchData out; uint8_t out_nbits; - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); const RCSwitchBase *protocol = &RC_SWITCH_PROTOCOLS[i]; if (protocol->decode(src, &out.code, &out_nbits) && out_nbits >= 3) { - out.protocol = i; + out.protocol = static_cast(i); return out; } } @@ -246,7 +238,7 @@ bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); } bool RCSwitchDumper::dump(RemoteReceiveData src) { - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); uint64_t out_data; uint8_t out_nbits; @@ -257,7 +249,7 @@ bool RCSwitchDumper::dump(RemoteReceiveData src) { buffer[j] = (out_data & ((uint64_t) 1 << (out_nbits - j - 1))) ? '1' : '0'; buffer[out_nbits] = '\0'; - ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", i, buffer); + ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", static_cast(i), buffer); // only send first decoded protocol return true; diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 3224c04fb29..9ccea4d15a5 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -16,9 +16,16 @@ class RCSwitchBase { public: using ProtocolData = RCSwitchData; - RCSwitchBase() = default; - RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, uint32_t one_high, - uint32_t one_low, bool inverted); + constexpr RCSwitchBase() = default; + constexpr RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, + uint32_t one_high, uint32_t one_low, bool inverted) + : sync_high_(sync_high), + sync_low_(sync_low), + zero_high_(zero_high), + zero_low_(zero_low), + one_high_(one_high), + one_low_(one_low), + inverted_(inverted) {} void one(RemoteTransmitData *dst) const; @@ -58,10 +65,28 @@ class RCSwitchBase { uint32_t zero_low_{}; uint32_t one_high_{}; uint32_t one_low_{}; - bool inverted_{}; + uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash }; -extern const RCSwitchBase RC_SWITCH_PROTOCOLS[9]; +// Constant-initialized and kept in flash on every platform. The decoder reads entries in place +// through a pointer, which ESP8266 only allows while every field is a whole word; copies out of +// the table go through rc_switch_protocol() +static_assert(sizeof(RCSwitchBase) == 7 * sizeof(uint32_t), "RCSwitchBase must stay word-only for flash reads"); +inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { + {0, 0, 0, 0, 0, 0, false}, + {350, 10850, 350, 1050, 1050, 350, false}, + {650, 6500, 650, 1300, 1300, 650, false}, + {3000, 7100, 400, 1100, 900, 600, false}, + {380, 2280, 380, 1140, 1140, 380, false}, + {3000, 7000, 500, 1000, 1000, 500, false}, + {10350, 450, 450, 900, 900, 450, true}, + {300, 9300, 150, 900, 900, 150, false}, + {250, 2500, 250, 1250, 250, 250, false}, +}; + +/// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with +/// progmem_memcpy so no byte load ever touches the flash table on ESP8266 +RCSwitchBase rc_switch_protocol(uint8_t index); uint64_t decode_binary_string(const std::string &data); diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 4d9bc55f216..5d1bba16b61 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -99,29 +99,48 @@ bool RemoteReceiverBinarySensorBase::on_receive(RemoteReceiveData src) { /* RemoteReceiverBase */ +// Slots are counted at code generation; a registration from C++ setup() has none +#ifdef REMOTE_BASE_LISTENER_COUNT +void RemoteReceiverBase::register_listener(RemoteReceiverListener *listener) { + if (this->listeners_.size() == REMOTE_BASE_LISTENER_COUNT) { + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("listener"), + LOG_STR_LITERAL("listener")); + return; + } + this->listeners_.push_back(listener); +} +#endif + +#ifdef REMOTE_BASE_DUMPER_COUNT void RemoteReceiverBase::register_dumper(RemoteReceiverDumperBase *dumper) { if (dumper->is_secondary()) { - this->secondary_dumpers_.push_back(dumper); - } else { + if (this->secondary_dumper_ == nullptr) { + this->secondary_dumper_ = dumper; + return; + } + } else if (this->dumpers_.size() != REMOTE_BASE_DUMPER_COUNT) { this->dumpers_.push_back(dumper); + return; } + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("dumper"), + LOG_STR_LITERAL("dumper")); } +#endif -void RemoteReceiverBase::call_listeners_() { +void RemoteReceiverBase::call_listeners_dumpers_() { +#ifdef REMOTE_BASE_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_receive(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); -} - -void RemoteReceiverBase::call_dumpers_() { +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT bool success = false; for (auto *dumper : this->dumpers_) { if (dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_))) success = true; } - if (!success) { - for (auto *dumper : this->secondary_dumpers_) - dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); - } + if (!success && this->secondary_dumper_ != nullptr) + this->secondary_dumper_->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); +#endif } void RemoteReceiverBinarySensorBase::dump_config() { LOG_BINARY_SENSOR("", "Remote Receiver Binary Sensor", this); } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 4e2ed4b71cb..67e5799bcaf 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -1,12 +1,14 @@ +#pragma once + +#include #include #include -#pragma once - #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::remote_base { @@ -141,6 +143,22 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +// Protocol shapes, checked where a protocol is used so a missing method fails at the use site +// instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode +// without encoding. +template +concept RemoteProtocolDecoder = requires(T proto, RemoteReceiveData src) { + { proto.decode(src) } -> std::same_as>; +}; +template +concept RemoteProtocolDumper = RemoteProtocolDecoder && requires(T proto, const typename T::ProtocolData &data) { + proto.dump(data); +}; +template +concept RemoteProtocolEncoder = requires(T proto, RemoteTransmitData *dst, const typename T::ProtocolData &data) { + proto.encode(dst, data); +}; + class RemoteTransmitterBase : public RemoteComponentBase { public: RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} @@ -162,8 +180,8 @@ class RemoteTransmitterBase : public RemoteComponentBase { this->temp_.reset(); return TransmitCall(this); } - template - void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { auto call = this->transmit(); Protocol().encode(call.get_data(), data); call.set_send_times(send_times); @@ -194,24 +212,37 @@ class RemoteReceiverDumperBase { class RemoteReceiverBase : public RemoteComponentBase { public: RemoteReceiverBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} - void register_listener(RemoteReceiverListener *listener) { this->listeners_.push_back(listener); } + // Slots are counted at code generation; without one the call fails at compile time with the same message + // the runtime check logs +#ifdef REMOTE_BASE_LISTENER_COUNT + void register_listener(RemoteReceiverListener *listener); +#else + template void register_listener(T *) { + static_assert(sizeof(T) == 0, "No listener slot: register it from to_code() with remote_base.add_listener"); + } +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT void register_dumper(RemoteReceiverDumperBase *dumper); +#else + template void register_dumper(T *) { + static_assert(sizeof(T) == 0, "No dumper slot: register it from to_code() with remote_base.add_dumper"); + } +#endif void set_tolerance(uint32_t tolerance, ToleranceMode tolerance_mode) { this->tolerance_ = tolerance; this->tolerance_mode_ = tolerance_mode; } protected: - void call_listeners_(); - void call_dumpers_(); - void call_listeners_dumpers_() { - this->call_listeners_(); - this->call_dumpers_(); - } + void call_listeners_dumpers_(); - std::vector listeners_; - std::vector dumpers_; - std::vector secondary_dumpers_; +#ifdef REMOTE_BASE_LISTENER_COUNT + StaticVector listeners_; +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT + StaticVector dumpers_; + RemoteReceiverDumperBase *secondary_dumper_{nullptr}; // runs only when no primary dumper matched +#endif RawTimings temp_; uint32_t tolerance_{25}; ToleranceMode tolerance_mode_{TOLERANCE_MODE_PERCENTAGE}; @@ -229,15 +260,14 @@ class RemoteReceiverBinarySensorBase : public binary_sensor::BinarySensorInitial /* TEMPLATES */ +// Protocols are used only through their concrete type (see the RemoteProtocol* concepts); encode/decode/dump +// stay non-virtual so unused ones link out template class RemoteProtocol { public: using ProtocolData = T; - virtual void encode(RemoteTransmitData *dst, const ProtocolData &data) = 0; - virtual optional decode(RemoteReceiveData src) = 0; - virtual void dump(const ProtocolData &data) = 0; }; -template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { +template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { public: RemoteReceiverBinarySensor() : RemoteReceiverBinarySensorBase() {} @@ -255,7 +285,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin T::ProtocolData data_; }; -template +template class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { @@ -276,8 +306,8 @@ class RemoteTransmittable { void set_transmitter(RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } protected: - template - void transmit_(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmitter_->transmit(data, send_times, send_wait); } RemoteTransmitterBase *transmitter_; @@ -298,7 +328,7 @@ template class RemoteTransmitterActionBase : public RemoteTransm virtual void encode(RemoteTransmitData *dst, Ts... x) = 0; }; -template class RemoteReceiverDumper : public RemoteReceiverDumperBase { +template class RemoteReceiverDumper : public RemoteReceiverDumperBase { public: bool dump(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 3582dac398b..8db025f812a 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -12,9 +12,9 @@ struct RoombaData { class RoombaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RoombaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RoombaData &data) override; + void encode(RemoteTransmitData *dst, const RoombaData &data); + optional decode(RemoteReceiveData src); + void dump(const RoombaData &data); }; DECLARE_REMOTE_PROTOCOL(Roomba) diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index 4f15d906e76..df4e1af8d8c 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -16,9 +16,9 @@ struct Samsung36Data { class Samsung36Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Samsung36Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Samsung36Data &data) override; + void encode(RemoteTransmitData *dst, const Samsung36Data &data); + optional decode(RemoteReceiveData src); + void dump(const Samsung36Data &data); }; DECLARE_REMOTE_PROTOCOL(Samsung36) diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index bb234d681de..dfa22ff85ce 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -14,9 +14,9 @@ struct SamsungData { class SamsungProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SamsungData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SamsungData &data) override; + void encode(RemoteTransmitData *dst, const SamsungData &data); + optional decode(RemoteReceiveData src); + void dump(const SamsungData &data); }; DECLARE_REMOTE_PROTOCOL(Samsung) diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index eb873e8b7dc..f83b2908b61 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -16,9 +16,9 @@ struct SonyData { class SonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SonyData &data) override; + void encode(RemoteTransmitData *dst, const SonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SonyData &data); }; DECLARE_REMOTE_PROTOCOL(Sony) diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 7caf5eab867..40a5c2daec9 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -17,9 +17,9 @@ struct SymphonyData { class SymphonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SymphonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SymphonyData &data) override; + void encode(RemoteTransmitData *dst, const SymphonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SymphonyData &data); }; DECLARE_REMOTE_PROTOCOL(Symphony) diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 8a853005acb..35d5af314cb 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -14,9 +14,9 @@ struct ToshibaAcData { class ToshibaAcProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ToshibaAcData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ToshibaAcData &data) override; + void encode(RemoteTransmitData *dst, const ToshibaAcData &data); + optional decode(RemoteReceiveData src); + void dump(const ToshibaAcData &data); }; DECLARE_REMOTE_PROTOCOL(ToshibaAc) diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 285c9f21257..8e965a5c739 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -16,9 +16,9 @@ struct TotoData { class TotoProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const TotoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const TotoData &data) override; + void encode(RemoteTransmitData *dst, const TotoData &data); + optional decode(RemoteReceiveData src); + void dump(const TotoData &data); }; DECLARE_REMOTE_PROTOCOL(Toto) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6e8c73d331a..866e108131e 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -112,17 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema), cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers, cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, + # pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses cv.SplitDefault( CONF_BUFFER_SIZE, - esp32="10000b", - esp32_c2="1000b", - esp32_c61="1000b", - esp8266="1000b", - bk72xx="1000b", - ln882x="1000b", - rtl87xx="1000b", - rp2="1000b", - ): cv.validate_bytes, + esp32=cv.UNDEFINED, + # the pulse ring needs a size; only RMT targets size themselves in setup() + **{ + f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b" + for variant in esp32_rmt.VARIANTS_NO_RMT + }, + esp8266="4000b", + bk72xx="4000b", + ln882x="4000b", + rtl87xx="4000b", + rp2="4000b", + ): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), @@ -221,11 +225,11 @@ async def to_code(config: ConfigType) -> None: dumpers = await remote_base.build_dumpers(config[CONF_DUMP]) for dumper in dumpers: - cg.add(var.register_dumper(dumper)) + remote_base.add_dumper(var, dumper) triggers = await remote_base.build_triggers(config) for trigger in triggers: - cg.add(var.register_listener(trigger)) + remote_base.add_listener(var, trigger) await cg.register_component(var, config) cg.add( @@ -233,7 +237,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_TOLERANCE][CONF_VALUE], config[CONF_TOLERANCE][CONF_TYPE] ) ) - cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) + if CONF_BUFFER_SIZE in config: + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_filter_us(config[CONF_FILTER])) cg.add(var.set_idle_us(config[CONF_IDLE])) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index bbcb7ae765b..b3e4649096b 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_ int32_t multiplier = ((int32_t) level << 1) - 1; uint32_t buffer_write = arg->buffer_write; arg->buffer[buffer_write++] = (int32_t) delta * multiplier; - if (buffer_write >= arg->buffer_size) { + if (buffer_write >= arg->buffer_entries) { buffer_write = 0; } @@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() { this->store_.idle_us = this->idle_us_; this->store_.filter_us = this->filter_us_; this->store_.pin = this->pin_->to_isr(); - this->store_.buffer = new int32_t[this->buffer_size_]; - this->store_.buffer_size = this->buffer_size_; + // rounded up so a size that is not a multiple of four never holds less than requested + this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t); + this->store_.buffer = new int32_t[this->store_.buffer_entries]; this->store_.prev_micros = micros(); this->store_.commit_micros = this->store_.prev_micros; this->store_.prev_level = this->pin_->digital_read(); @@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG( TAG, "Remote Receiver:\n" - " Buffer Size: %" PRIu32 "\n" + " Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n" " Tolerance: %" PRIu32 "%s\n" " Filter out pulses shorter than: %" PRIu32 " us\n" " Signal is done after %" PRIu32 " us of no changes", - this->buffer_size_, this->tolerance_, + this->buffer_size_, this->store_.buffer_entries, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); @@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() { while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { reserve_size++; temp_read++; - if (temp_read >= s.buffer_size) { + if (temp_read >= s.buffer_entries) { temp_read = 0; } } @@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() { // read the buffer for (uint32_t i = 0; i < reserve_size + 1; i++) { this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); - if (s.buffer_read >= s.buffer_size) { + if (s.buffer_read >= s.buffer_entries) { s.buffer_read = 0; } } diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index f9ec054fe31..6f93979b183 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore { uint32_t buffer_read{0}; volatile uint32_t commit_micros{0}; volatile uint32_t prev_micros{0}; - uint32_t buffer_size{1000}; + uint32_t buffer_entries{0}; uint32_t filter_us{10}; uint32_t idle_us{10000}; ISRInternalGPIOPin pin; @@ -47,7 +47,7 @@ struct RemoteReceiverComponentStore { /// The position last read from volatile uint32_t buffer_read{0}; bool overflow{false}; - uint32_t buffer_size{1000}; + uint32_t buffer_size{0}; uint32_t receive_size{0}; uint32_t filter_symbols{0}; esp_err_t error{ESP_OK}; @@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, protected: #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void decode_rmt_(rmt_symbol_word_t *item, size_t item_count); + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; bool with_dma_{false}; uint32_t carrier_frequency_{0}; uint8_t carrier_duty_percent_{100}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; #endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) @@ -101,7 +101,7 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, HighFrequencyLoopRequester high_freq_; #endif - uint32_t buffer_size_{}; + uint32_t buffer_size_{}; // 0 on RMT targets: sized from receive_symbols in setup() uint32_t filter_us_{10}; uint32_t idle_us_{10000}; }; diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 632ca9763ae..64392aa7eeb 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -1,5 +1,6 @@ #include "remote_receiver.h" #include "esphome/core/log.h" +#include "esphome/core/wake.h" #ifdef USE_ESP32 #include @@ -10,28 +11,41 @@ namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver"; +static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; - rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write); + const uint32_t buffer_write = store->buffer_write; + rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write); uint32_t event_size = sizeof(rmt_rx_done_event_data_t); - uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); + uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); if (next_write + event_size + store->receive_size > store->buffer_size) { next_write = 0; } if (store->buffer_read - next_write < event_size + store->receive_size) { - next_write = store->buffer_write; + next_write = buffer_write; store->overflow = true; } if (event->num_symbols <= store->filter_symbols) { - next_write = store->buffer_write; + next_write = buffer_write; } store->error = rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config); event_buffer->num_symbols = event->num_symbols; event_buffer->received_symbols = event->received_symbols; + const bool stored = next_write != buffer_write; store->buffer_write = next_write; - return false; + // a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of + // waiting out the loop interval; filtered noise and dropped frames leave nothing to read + BaseType_t task_woken = pdFALSE; + if (stored || store->error != ESP_OK) + wake_loop_isrsafe(&task_woken); + return task_woken != pdFALSE; +} + +void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); } void RemoteReceiverComponent::setup() { @@ -46,13 +60,8 @@ void RemoteReceiverComponent::setup() { channel.flags.with_dma = this->with_dma_; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_rx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel")); return; } if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -62,9 +71,7 @@ void RemoteReceiverComponent::setup() { } error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } @@ -76,9 +83,7 @@ void RemoteReceiverComponent::setup() { carrier.flags.polarity_active_low = this->pin_->is_inverted(); error = rmt_apply_carrier(this->channel_, &carrier); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } @@ -88,9 +93,7 @@ void RemoteReceiverComponent::setup() { callbacks.on_recv_done = rmt_callback; error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_rx_register_event_callbacks"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks")); return; } @@ -104,46 +107,44 @@ void RemoteReceiverComponent::setup() { this->store_.config.signal_range_max_ns = this->idle_us_ * 1000; this->store_.filter_symbols = this->filter_symbols_; this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t); - this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_); + // one slot per pending rmt_receive; two are the floor (one filling while one is decoded), and + // the default of four covers a few frames queued across a stalled loop pass + const uint32_t slot_size = event_size + this->store_.receive_size; + this->store_.buffer_size = + this->buffer_size_ != 0 ? std::max(slot_size * 2, this->buffer_size_) : slot_size * DEFAULT_BUFFER_SLOTS; this->store_.buffer = new uint8_t[this->store_.buffer_size]; error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_receive"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_receive")); return; } } void RemoteReceiverComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "Remote Receiver:\n" - " Clock resolution: %" PRIu32 " hz\n" - " RMT symbols: %" PRIu32 "\n" - " Filter symbols: %" PRIu32 "\n" - " Receive symbols: %" PRIu32 "\n" - " Tolerance: %" PRIu32 "%s\n" - " Carrier frequency: %" PRIu32 " hz\n" - " Carrier duty: %u%%\n" - " Filter out pulses shorter than: %" PRIu32 " us\n" - " Signal is done after %" PRIu32 " us of no changes", - this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, - this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", - this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); + ESP_LOGCONFIG( + TAG, + "Remote Receiver:\n" + " Clock resolution: %" PRIu32 " hz\n" + " RMT symbols: %" PRIu32 "\n" + " Filter symbols: %" PRIu32 "\n" + " Receive symbols: %" PRIu32 "\n" + " Buffer size: %" PRIu32 " bytes\n" + " Tolerance: %" PRIu32 "%s\n" + " Carrier frequency: %" PRIu32 " hz\n" + " Carrier duty: %u%%\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", + this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, + this->store_.buffer_size, this->tolerance_, + (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), + this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteReceiverComponent::loop() { if (this->store_.error != ESP_OK) { - ESP_LOGE(TAG, "Receive error"); - this->error_code_ = this->store_.error; - this->error_string_ = "in rmt_callback"; - this->mark_failed(); + this->fail_(this->store_.error, LOG_STR("in rmt_callback")); } if (this->store_.overflow) { ESP_LOGW(TAG, "Buffer overflow"); diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 58392c48abc..68eac315d51 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -20,8 +20,7 @@ from esphome.const import ( CONF_VALUE, PlatformFramework, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -38,11 +37,6 @@ remote_transmitter_ns = cg.esphome_ns.namespace("remote_transmitter") RemoteTransmitterComponent = remote_transmitter_ns.class_( "RemoteTransmitterComponent", remote_base.RemoteTransmitterBase, cg.Component ) -DigitalWriteAction = remote_transmitter_ns.class_( - "DigitalWriteAction", - automation.Action, - cg.Parented.template(RemoteTransmitterComponent), -) # Keep in sync with the USE_LIBRETINY_VARIANT_RTL8720C / REMOTE_TRANSMITTER_BK_PWM gates in @@ -138,23 +132,12 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "remote_transmitter.digital_write", - DigitalWriteAction, DIGITAL_WRITE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VALUE, "digital_write", cg.bool_), + id_key=CONF_TRANSMITTER_ID, ) -async def digital_write_action_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_TRANSMITTER_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.bool_) - cg.add(var.set_value(template_)) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h deleted file mode 100644 index a1b0926451c..00000000000 --- a/esphome/components/remote_transmitter/automation.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/components/remote_transmitter/remote_transmitter.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "esphome/core/helpers.h" - -namespace esphome::remote_transmitter { - -template -class DigitalWriteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, value) - void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); } -}; - -} // namespace esphome::remote_transmitter diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 4db4e80a60e..99e1ce9504d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -141,6 +141,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); void configure_rmt_(); void wait_for_rmt_(); @@ -156,8 +158,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; rmt_encoder_handle_t encoder_{NULL}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; bool inverted_{false}; bool non_blocking_{false}; #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 3c9a12d472f..6d27be8d472 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size } #endif +void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() { if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) { ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_); } - - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteTransmitterComponent::digital_write(bool value) { @@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() { #endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_tx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel")); return; } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() { encoder.min_chunk_size = 1; error = rmt_new_simple_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_simple_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_simple_encoder")); return; } #else @@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() { memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_copy_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_copy_encoder")); return; } #endif error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } this->digital_write(open_drain || this->inverted_); @@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() { error = rmt_apply_carrier(this->channel_, &carrier); } if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index f1ebd180cc0..16d2d5dc9e2 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); } @@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); } else { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); } else { @@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const { bool has_ring_buffer_data = false; if (this->requires_resampling_()) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { has_ring_buffer_data = (temp_ring_buffer->available() > 0); } } @@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { this_resampler->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index 974db0cec48..4baac6472c1 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartButton final : public button::Button, public Component { public: + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} + void dump_config() override; protected: diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index dc9ec8eadcd..03cf03f1662 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) RestartSwitch()` would zero-fill .bss that is already zero. + RestartSwitch() {} + void dump_config() override; protected: diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9863379b791..fed8365e9ee 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -25,29 +25,6 @@ RFBridgeComponent = rf_bridge_ns.class_( RFBridgeData = rf_bridge_ns.struct("RFBridgeData") RFBridgeAdvancedData = rf_bridge_ns.struct("RFBridgeAdvancedData") -RFBridgeSendCodeAction = rf_bridge_ns.class_( - "RFBridgeSendCodeAction", automation.Action -) -RFBridgeSendAdvancedCodeAction = rf_bridge_ns.class_( - "RFBridgeSendAdvancedCodeAction", automation.Action -) - -RFBridgeLearnAction = rf_bridge_ns.class_("RFBridgeLearnAction", automation.Action) - -RFBridgeStartAdvancedSniffingAction = rf_bridge_ns.class_( - "RFBridgeStartAdvancedSniffingAction", automation.Action -) -RFBridgeStopAdvancedSniffingAction = rf_bridge_ns.class_( - "RFBridgeStopAdvancedSniffingAction", automation.Action -) - -RFBridgeStartBucketSniffingAction = rf_bridge_ns.class_( - "RFBridgeStartBucketSniffingAction", automation.Action -) - -RFBridgeBeepAction = rf_bridge_ns.class_("RFBridgeBeepAction", automation.Action) - -RFBridgeSendRawAction = rf_bridge_ns.class_("RFBridgeSendRawAction", automation.Action) CONF_ON_CODE_RECEIVED = "on_code_received" CONF_ON_ADVANCED_CODE_RECEIVED = "on_advanced_code_received" @@ -110,74 +87,48 @@ RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_code", - RFBridgeSendCodeAction, RFBRIDGE_SEND_CODE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_code(rf_bridge::RFBridgeData{{.sync = {}, .low = {}, .high = {}, .code = {}}})", + ( + (CONF_SYNC, cg.uint16), + (CONF_LOW, cg.uint16), + (CONF_HIGH, cg.uint16), + (CONF_CODE, cg.uint32), + ), + ), ) -async def rf_bridge_send_code_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_SYNC], args, cg.uint16) - cg.add(var.set_sync(template_)) - template_ = await cg.templatable(config[CONF_LOW], args, cg.uint16) - cg.add(var.set_low(template_)) - template_ = await cg.templatable(config[CONF_HIGH], args, cg.uint16) - cg.add(var.set_high(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) - return var RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) -@automation.register_action( - "rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA, synchronous=True +automation.register_apply_action( + "rf_bridge.learn", RFBRIDGE_ID_SCHEMA, automation.ApplyCall("learn()") ) -async def rf_bridge_learnx_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.start_advanced_sniffing", - RFBridgeStartAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("start_advanced_sniffing()"), ) -async def rf_bridge_start_advanced_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.stop_advanced_sniffing", - RFBridgeStopAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("stop_advanced_sniffing()"), ) -async def rf_bridge_stop_advanced_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.start_bucket_sniffing", - RFBridgeStartBucketSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("start_bucket_sniffing()"), ) -async def rf_bridge_start_bucket_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( @@ -190,22 +141,18 @@ RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_advanced_code", - RFBridgeSendAdvancedCodeAction, RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_advanced_code(rf_bridge::RFBridgeAdvancedData{{.length = {}, .protocol = {}, .code = {}}})", + ( + (CONF_LENGTH, cg.uint8), + (CONF_PROTOCOL, cg.uint8), + (CONF_CODE, cg.std_string), + ), + ), ) -async def rf_bridge_send_advanced_code_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_LENGTH], args, cg.uint8) - cg.add(var.set_length(template_)) - template_ = await cg.templatable(config[CONF_PROTOCOL], args, cg.uint8) - cg.add(var.set_protocol(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) - cg.add(var.set_code(template_)) - return var RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( @@ -216,18 +163,11 @@ RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_raw", - RFBridgeSendRawAction, RFBRIDGE_SEND_RAW_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_RAW, "send_raw", cg.std_string), ) -async def rf_bridge_send_raw_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_RAW], args, cg.std_string) - cg.add(var.set_raw(template_)) - return var RFBRIDGE_BEEP_SCHEMA = cv.Schema( @@ -238,12 +178,8 @@ RFBRIDGE_BEEP_SCHEMA = cv.Schema( ) -@automation.register_action( - "rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA, synchronous=True +automation.register_apply_action( + "rf_bridge.beep", + RFBRIDGE_BEEP_SCHEMA, + automation.ApplyField(CONF_DURATION, "beep", cg.uint16), ) -async def rf_bridge_beep_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint16) - cg.add(var.set_duration(template_)) - return var diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 549cce72dfd..a4a4da5d8c0 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -18,6 +18,16 @@ void RFBridgeComponent::ack_() { } bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { + if (this->bucket_frame_candidate_ && byte == RF_CODE_START) { + // A queued next frame proves the trailing 0x55 really was the bucket + // frame's terminator: Portisch builds pulse entries from alternating + // signal edges, so the two level bits inside one pulse byte are always + // opposite — 0xAA (two high-level nibbles) cannot occur in pulse data. + // Finalize before this byte starts the new frame, so back-to-back + // deliveries are split even when loop() never observed a quiet gap + // between them. + this->finish_bucket_frame_(); + } size_t at = this->rx_buffer_.size(); this->rx_buffer_.push_back(byte); const uint8_t *raw = &this->rx_buffer_[0]; @@ -84,26 +94,21 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { break; } case RF_CODE_RFIN_BUCKET: { - if (byte != RF_CODE_STOP) { - return true; + if (at == 2) { + // The count byte: Portisch sends at most 7 buckets + sync, so 0 or + // >8 cannot be a genuine capture — reject before it can occupy the + // buffer for a full frame timeout. + return byte != 0 && byte <= B1_MAX_BUCKET_COUNT; } - - uint8_t buckets = raw[2] << 1; - std::string str; - char next_byte[3]; // 2 hex chars + null - - for (uint32_t i = 0; i <= at; i++) { - buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); - str += next_byte; - if ((i > 3) && buckets) { - buckets--; - } - if ((i < 3) || (buckets % 2) || (i == at - 1)) { - str += " "; - } - } - ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); - break; + // 0x55 is legal DATA inside a B1 frame: bucket durations are sent + // with only their HIGH byte masked to 7 bits, so a duration such as + // 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55 + // must therefore not end the capture. The header declares the table + // length (raw[2] pairs), so a 0x55 there is always data; one at or + // past the first pulse index is a terminator CANDIDATE, confirmed + // once the UART goes quiet (finish_bucket_frame_ in loop()). + this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast(raw[2]) * 2; + return true; } default: ESP_LOGW(TAG, "Unknown action: 0x%02X", action); @@ -119,6 +124,47 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return false; } +void RFBridgeComponent::finish_bucket_frame_() { + if (this->rx_buffer_.size() < 4) { + // The candidate flag requires a header + non-empty bucket table, so + // this cannot happen while flag and buffer stay consistent; guard the + // raw[2] / size-1 reads against any future divergence anyway. + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + return; + } + const uint8_t *raw = this->rx_buffer_.data(); + const size_t at = this->rx_buffer_.size() - 1; + + uint8_t buckets = raw[2] << 1; + std::string str; + char next_byte[3]; // 2 hex chars + null + + for (uint32_t i = 0; i <= at; i++) { + buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); + str += next_byte; + if ((i > 3) && buckets) { + buckets--; + } + if ((i < 3) || (buckets % 2) || (i == at - 1)) { + str += " "; + } + } + ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); + + // Deliberately NOT ACKed: Portisch's B1 command handler leaves its + // last_sniffing_command at the previous mode (RF_CODE_RFIN), and its + // host-ACK handler re-arms sniffing from that stale value — so ACKing a + // bucket delivery silently reverts the radio to standard sniffing and + // ends bucket capture. Its delivery path is fire-and-forget and never + // waits for a host ACK. Stock Itead firmware never sends B1 frames, so + // suppressing this ACK cannot change stock-firmware behavior. + // https://github.com/esphome/esphome/issues/17682 + + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; +} + void RFBridgeComponent::write_byte_str_(const std::string &codes) { uint8_t code; int size = codes.length(); @@ -130,12 +176,31 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) { void RFBridgeComponent::loop() { const uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_bridge_byte_ > 50) { + size_t avail = this->available(); + if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) { + // The trailing 0x55 was followed by UART quiet, so it really was the + // frame terminator and not an interior data byte. + this->finish_bucket_frame_(); + this->last_bridge_byte_ = now; + } + const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET; + if (receiving_bucket) { + // Never declare an in-progress bucket frame dead while its continuation + // bytes are already queued: a stalled loop() otherwise discards a live + // frame that the UART buffer proves is still arriving. + if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) { + ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)", + static_cast(this->rx_buffer_.size())); + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + this->last_bridge_byte_ = now; + } + } else if (now - this->last_bridge_byte_ > 50) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; this->last_bridge_byte_ = now; } - size_t avail = this->available(); while (avail > 0) { uint8_t buf[64]; size_t to_read = std::min(avail, sizeof(buf)); @@ -146,12 +211,14 @@ void RFBridgeComponent::loop() { for (size_t i = 0; i < to_read; i++) { if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } if (this->parse_bridge_byte_(buf[i])) { ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); this->last_bridge_byte_ = now; } else { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } } } diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 5ad75650abb..200ef978109 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -5,7 +5,6 @@ #include "esphome/core/component.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" namespace esphome::rf_bridge { @@ -30,6 +29,17 @@ static const uint8_t RF_CODE_BEEP = 0xC0; static const uint8_t RF_CODE_STOP = 0x55; static const uint8_t RF_DEBOUNCE = 200; static const size_t MAX_RX_BUFFER_SIZE = 512; +// ~10 byte times at 19200 baud: long enough to prove the UART went quiet +// after a possible bucket-frame terminator, short enough to finish well +// before the next radio capture can be delivered. +static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5; +// Portisch drains a B1 frame's header, bucket table, and pulse data as +// separate UART writes, so an in-progress bucket frame tolerates a longer +// inter-region gap than the generic 50 ms inter-byte timeout. +static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250; +// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync +// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol. +static const uint8_t B1_MAX_BUCKET_COUNT = 8; struct RFBridgeData { uint16_t sync; @@ -67,115 +77,15 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { void ack_(); void decode_(); bool parse_bridge_byte_(uint8_t byte); + void finish_bucket_frame_(); void write_byte_str_(const std::string &codes); std::vector rx_buffer_; uint32_t last_bridge_byte_{0}; + bool bucket_frame_candidate_{false}; CallbackManager data_callback_; CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction final : public Action { - public: - RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, sync) - TEMPLATABLE_VALUE(uint16_t, low) - TEMPLATABLE_VALUE(uint16_t, high) - TEMPLATABLE_VALUE(uint32_t, code) - - void play(const Ts &...x) { - RFBridgeData data{}; - data.sync = this->sync_.value(x...); - data.low = this->low_.value(x...); - data.high = this->high_.value(x...); - data.code = this->code_.value(x...); - this->parent_->send_code(data); - } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeSendAdvancedCodeAction final : public Action { - public: - RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint8_t, length) - TEMPLATABLE_VALUE(uint8_t, protocol) - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) { - RFBridgeAdvancedData data{}; - data.length = this->length_.value(x...); - data.protocol = this->protocol_.value(x...); - data.code = this->code_.value(x...); - this->parent_->send_advanced_code(data); - } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeLearnAction final : public Action { - public: - RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->learn(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStartAdvancedSniffingAction final : public Action { - public: - RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->start_advanced_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStopAdvancedSniffingAction final : public Action { - public: - RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->stop_advanced_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStartBucketSniffingAction final : public Action { - public: - RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->start_bucket_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeSendRawAction final : public Action { - public: - RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, raw) - - void play(const Ts &...x) { this->parent_->send_raw(this->raw_.value(x...)); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeBeepAction final : public Action { - public: - RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, duration) - - void play(const Ts &...x) { this->parent_->beep(this->duration_.value(x...)); } - - protected: - RFBridgeComponent *parent_; -}; - } // namespace esphome::rf_bridge diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 286267baed9..0d2f069c421 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" namespace esphome::rotary_encoder { @@ -106,15 +105,4 @@ class RotaryEncoderSensor final : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction final : public Action { - public: - RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} - TEMPLATABLE_VALUE(int, value) - - void play(const Ts &...x) override { this->encoder_->set_value(this->value_.value(x...)); } - - protected: - RotaryEncoderSensor *encoder_; -}; - } // namespace esphome::rotary_encoder diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 72722ec4b13..ab5442e6764 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -15,8 +15,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") @@ -42,9 +40,6 @@ CONF_PUBLISH_INITIAL_VALUE = "publish_initial_value" RotaryEncoderSensor = rotary_encoder_ns.class_( "RotaryEncoderSensor", sensor.Sensor, cg.Component ) -RotaryEncoderSetValueAction = rotary_encoder_ns.class_( - "RotaryEncoderSetValueAction", automation.Action -) def validate_min_max_value(config: ConfigType) -> ConfigType: @@ -118,25 +113,13 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "sensor.rotary_encoder.set_value", - RotaryEncoderSetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(RotaryEncoderSensor), cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.int_), ) -async def sensor_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index 18311416c30..f568aedcbdf 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation, core +from esphome import automation import esphome.codegen as cg from esphome.components import audio, speaker import esphome.config_validation as cv @@ -9,9 +9,7 @@ from esphome.const import ( CONF_OUTPUT_SPEAKER, CONF_SAMPLE_RATE, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] @@ -20,7 +18,6 @@ CONF_TARGET_SPEAKER = "target_speaker" router_ns = cg.esphome_ns.namespace("router") Router = router_ns.class_("Router", cg.Component, speaker.Speaker) -SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action) SpeakerPtr = speaker.Speaker.operator("ptr") @@ -93,9 +90,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_output(spk)) -@automation.register_action( +automation.register_apply_action( "router.speaker.switch_output", - SwitchOutputAction, cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(Router), @@ -104,19 +100,5 @@ async def to_code(config: ConfigType) -> None: ), } ), - synchronous=True, + automation.ApplyField(CONF_TARGET_SPEAKER, "switch_to_output", SpeakerPtr), ) -async def switch_output_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - target = config[CONF_TARGET_SPEAKER] - if not isinstance(target, core.Lambda): - target = await cg.get_variable(target) - template_ = await cg.templatable(target, args, SpeakerPtr) - cg.add(var.set_target(template_)) - return var diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index f4bf7420ab0..dd2428e4df4 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esp_timer.h" @@ -12,6 +14,9 @@ namespace esphome::router { static const char *const TAG = "router.speaker"; +// Maximum time to wait for the active output to report running after start() before giving up +static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; + static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { uint32_t current = var.load(std::memory_order_acquire); uint32_t subtracted = 0; @@ -72,6 +77,7 @@ void Router::loop() { this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + this->state_start_ms_ = App.get_loop_component_start_time(); active->start(); } return; @@ -86,10 +92,17 @@ void Router::loop() { // set_audio_stream_info() and never reaches the output on its own; if the format // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it // down before the output's play()-side auto-start locks in the stale format. - if (active->is_stopped()) { + // While STARTING, ignore a transient stopped report as speaker running state + // is set asynchronously from start(). Timeout if the speaker never transitions. + if (this->state_ == speaker::STATE_STARTING) { + if (active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Active output did not start; giving up"); + this->state_ = speaker::STATE_STOPPED; + } + } else if (active->is_stopped()) { this->state_ = speaker::STATE_STOPPED; - } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { - this->state_ = speaker::STATE_RUNNING; } } @@ -133,6 +146,8 @@ void Router::start() { this->frames_in_pipeline_.store(0, std::memory_order_release); this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + // May run on a producer task, so the cached loop timestamp is not usable here + this->state_start_ms_ = millis(); this->get_active_output()->start(); } diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 801d0906cee..2ce4c1a906d 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -3,7 +3,6 @@ #ifdef USE_ESP32 #include "esphome/components/speaker/speaker.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -59,6 +58,9 @@ class Router final : public Component, public speaker::Speaker { // frames_in_pipeline_. std::atomic frames_in_pipeline_{0}; + // Set when entering STATE_STARTING; used to time out a start the output never acts on + uint32_t state_start_ms_{0}; + bool cached_pause_{false}; void apply_cached_state_to_active_(); @@ -77,16 +79,6 @@ class Router final : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction final : public Action { - public: - explicit SwitchOutputAction(Router *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(speaker::Speaker *, target) - void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); } - - protected: - Router *parent_; -}; - } // namespace esphome::router #endif // USE_ESP32 diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index dae7df26c32..23346ca6f4b 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -197,20 +197,21 @@ def _parse_platform_version(value: Any) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain -# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" +# develop-branch commit carrying the arduino-pico 6.1.0 update; it predates +# 6.1.1, so the boards that release adds have no board JSON on it yet; replace +# with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 0, 0), None), + "dev": (cv.Version(6, 1, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 4b2f9769b01..08536fd0537 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "soldered_nula_node_rp2040": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -1430,6 +1442,19 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "waveshare_rp2350_core": { + "LED": 39, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_lcd_0_96": { "MISO": 16, "MOSI": 19, @@ -1467,6 +1492,18 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "waveshare_rp2350_poe_eth": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_zero": { "MISO": 4, "MOSI": 3, @@ -2127,6 +2164,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_node_rp2040": { + "name": "Soldered Electronics NULA Node", + "mcu": "rp2040", + "max_pin": 29, + "wifi": True, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", @@ -2268,6 +2311,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "waveshare_rp2350_core": { + "name": "Waveshare RP2350 Core", + "mcu": "rp2350", + "max_pin": 47, + "die": "B", + }, "waveshare_rp2350_lcd_0_96": { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", @@ -2286,6 +2335,12 @@ BOARDS = { "max_pin": 29, "die": "A", }, + "waveshare_rp2350_poe_eth": { + "name": "Waveshare RP2350-POE-ETH", + "mcu": "rp2350", + "max_pin": 29, + "die": "A", + }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea216371..9bcdc8bee4e 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -55,8 +55,7 @@ namespace esphome::rp2 { static const char *const TAG = "rp2.crash"; -// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). -// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +// Filled from the watchdog scratch registers on the first read. static struct CrashData { bool valid; uint32_t pc; @@ -64,11 +63,24 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -bool crash_handler_has_data() { return s_crash_data.valid; } +// Logger::pre_setup() logs the record before App.pre_setup() reaches +// arch_init(), so the first caller reads it and later calls are no-ops. +// The read clears the scratch registers, so it must not run twice, and +// arch_init() keeps its call so the read precedes watchdog_enable(), which +// overwrites scratch[4]. +static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_handler_has_data() { + crash_handler_read_and_clear(); + return s_crash_data.valid; +} void crash_handler_read_and_clear() { + if (s_crash_data_read) + return; + s_crash_data_read = true; s_crash_data.valid = false; uint32_t magic = watchdog_hw->scratch[0]; if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { @@ -97,7 +109,7 @@ void crash_handler_read_and_clear() { // the device crashes again during boot, and allowing the CLI's process_stacktrace // to match and decode each address individually. void crash_handler_log() { - if (!s_crash_data.valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b0..3aec80b63b2 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -9,12 +9,13 @@ namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. +/// Only the first call reads; later calls are no-ops. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::rp2 diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index 0dbb124a267..b95da9bcdbc 100644 --- a/esphome/components/rp2/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -9,6 +9,8 @@ namespace rp2 { static const char *const TAG = "rp2"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { + flags = + flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) return INPUT; } else if (flags == gpio::FLAG_OUTPUT) { diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 8af57924a2b..adf45eeaf7f 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -20,7 +20,7 @@ namespace esphome::rp2040_ble { namespace { -// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.1's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a0344e80542..49b10f7d201 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -13,7 +11,6 @@ DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") RP2040PWM = rp2040_pwm_ns.class_("RP2040PWM", output.FloatOutput, cg.Component) -SetFrequencyAction = rp2040_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( @@ -36,25 +33,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(config[CONF_FREQUENCY])) -@automation.register_action( +automation.register_apply_action( "output.rp2040_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(RP2040PWM), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def rp2040_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 8263113168d..7808e2af01c 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -3,7 +3,6 @@ #ifdef USE_RP2 #include "esphome/components/output/float_output.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -39,19 +38,6 @@ class RP2040PWM final : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - RP2040PWM *parent_; -}; - } // namespace esphome::rp2040_pwm #endif // USE_RP2 diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index b744aee31c8..12857a556fc 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -42,7 +42,6 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( ) StartScanAction = rp2_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = rp2_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -156,9 +155,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "rp2_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -166,14 +164,5 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/rp2_ble_tracker/automation.h b/esphome/components/rp2_ble_tracker/automation.h index a3cede236af..7c64975077c 100644 --- a/esphome/components/rp2_ble_tracker/automation.h +++ b/esphome/components/rp2_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::rp2_ble_tracker #endif // USE_RP2 diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c0afc0607e0..f2f25741f33 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() { } void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void RpiDpiRgb::dump_config() { diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index b6c41835864..4f4d17b441e 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -6,8 +6,6 @@ from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -20,9 +18,6 @@ CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) -PlayAction = rtttl_ns.class_("PlayAction", automation.Action) -StopAction = rtttl_ns.class_("StopAction", automation.Action) -IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -101,9 +96,8 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "rtttl.play", - PlayAction, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), @@ -111,57 +105,26 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_RTTTL, ), - synchronous=True, + automation.ApplyField(CONF_RTTTL, "play", cg.std_string), ) -async def rtttl_play_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RTTTL], args, cg.std_string) - cg.add(var.set_value(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "rtttl.stop", - StopAction, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), - synchronous=True, + automation.ApplyCall("stop()"), ) -async def rtttl_stop_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]) - return var -@automation.register_condition( +automation.register_apply_condition( "rtttl.is_playing", - IsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), + "is_playing()", ) -async def rtttl_is_playing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 256bdce5f21..7a32b79b177 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -116,25 +115,4 @@ class Rtttl final : public Component { #endif }; -template class PlayAction final : public Action { - public: - PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} - TEMPLATABLE_VALUE(std::string, value) - - void play(const Ts &...x) override { this->rtttl_->play(this->value_.value(x...)); } - - protected: - Rtttl *rtttl_; -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::rtttl diff --git a/esphome/components/runtime_image/image_format.cpp b/esphome/components/runtime_image/image_format.cpp index 9db84904158..368c103d15f 100644 --- a/esphome/components/runtime_image/image_format.cpp +++ b/esphome/components/runtime_image/image_format.cpp @@ -1,5 +1,6 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "image_format.h" namespace esphome::runtime_image { @@ -26,6 +27,8 @@ static constexpr MimeLookup MIME_LOOKUP_TABLE[] = { #endif }; +PROGMEM_STRING_TABLE(ImageFormatStrings, "AUTO", "BMP", "JPEG", "PNG", "QOI", "UNKNOWN"); + const char *get_mime_type_for_format(ImageFormat format) { for (const auto &entry : MIME_LOOKUP_TABLE) { if (entry.format == format) { @@ -44,4 +47,8 @@ std::optional get_format_for_mime_type(const char *mime_type) { return std::nullopt; } +const LogString *get_format_name(ImageFormat format) { + return ImageFormatStrings::get_log_str(static_cast(format), ImageFormatStrings::LAST_INDEX); +} + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h index 72bd81a06ea..60dbee39bfa 100644 --- a/esphome/components/runtime_image/image_format.h +++ b/esphome/components/runtime_image/image_format.h @@ -1,6 +1,7 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::runtime_image { @@ -25,5 +26,7 @@ enum ImageFormat { const char *get_mime_type_for_format(ImageFormat format); /// Case-insensitive substring match of known media types; nullopt if none found std::optional get_format_for_mime_type(const char *mime_type); +/// Human-readable name for a format; "UNKNOWN" if not recognized +const LogString *get_format_name(ImageFormat format); } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index ef92d0d7073..5c14d398071 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -186,14 +186,15 @@ bool RuntimeImage::begin_decode(size_t expected_size, ImageFormat format) { // An idle decoder for a different format cannot be reused if (this->decoder_ != nullptr && this->decoder_->get_format() != format) { - ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), format); + ESP_LOGD(TAG, "Decoder format mismatch: current: %s, new: %s", + LOG_STR_ARG(get_format_name(this->decoder_->get_format())), LOG_STR_ARG(get_format_name(format))); this->decoder_ = nullptr; } if (!this->decoder_) { this->decoder_ = this->create_decoder_(format); if (!this->decoder_) { - ESP_LOGE(TAG, "Failed to create decoder for format %d", format); + ESP_LOGE(TAG, "Failed to create decoder for format %s", LOG_STR_ARG(get_format_name(format))); return false; } } @@ -357,7 +358,7 @@ size_t RuntimeImage::get_buffer_size(int width, int height) const { int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) { - ESP_LOGV(TAG, "Creating decoder for format %d", format); + ESP_LOGV(TAG, "Creating decoder for format %s", LOG_STR_ARG(get_format_name(format))); switch (format) { #ifdef USE_RUNTIME_IMAGE_BMP case BMP: @@ -379,7 +380,7 @@ std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration"); return nullptr; default: - ESP_LOGE(TAG, "Unsupported image format: %d", format); + ESP_LOGE(TAG, "Unsupported image format: %s", LOG_STR_ARG(get_format_name(format))); return nullptr; } } diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index 1ea9c457f3e..abbda5872b2 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -129,36 +129,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_PRESSURE in config: - sens = await sensor.new_sensor(config[CONF_PRESSURE]) - cg.add(var.set_pressure(sens)) - if CONF_ACCELERATION in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION]) - cg.add(var.set_acceleration(sens)) - if CONF_ACCELERATION_X in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_X]) - cg.add(var.set_acceleration_x(sens)) - if CONF_ACCELERATION_Y in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_Y]) - cg.add(var.set_acceleration_y(sens)) - if CONF_ACCELERATION_Z in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_Z]) - cg.add(var.set_acceleration_z(sens)) - if CONF_BATTERY_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_VOLTAGE]) - cg.add(var.set_battery_voltage(sens)) - if CONF_TX_POWER in config: - sens = await sensor.new_sensor(config[CONF_TX_POWER]) - cg.add(var.set_tx_power(sens)) - if CONF_MOVEMENT_COUNTER in config: - sens = await sensor.new_sensor(config[CONF_MOVEMENT_COUNTER]) - cg.add(var.set_movement_counter(sens)) - if CONF_MEASUREMENT_SEQUENCE_NUMBER in config: - sens = await sensor.new_sensor(config[CONF_MEASUREMENT_SEQUENCE_NUMBER]) - cg.add(var.set_measurement_sequence_number(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_ACCELERATION, var.set_acceleration) + await sensors(CONF_ACCELERATION_X, var.set_acceleration_x) + await sensors(CONF_ACCELERATION_Y, var.set_acceleration_y) + await sensors(CONF_ACCELERATION_Z, var.set_acceleration_z) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_TX_POWER, var.set_tx_power) + await sensors(CONF_MOVEMENT_COUNTER, var.set_movement_counter) + await sensors(CONF_MEASUREMENT_SEQUENCE_NUMBER, var.set_measurement_sequence_number) diff --git a/esphome/components/rx8025t/__init__.py b/esphome/components/rx8025t/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp new file mode 100644 index 00000000000..fee2c83a5fe --- /dev/null +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -0,0 +1,128 @@ +#include "rx8025t.h" +#include "esphome/core/log.h" + +// Datasheet: +// - https://support.epson.biz/td/api/doc_check.php?dl=app_RX8025T + +namespace esphome::rx8025t { + +static constexpr uint8_t RX8025T_REG_SEC = 0x00; +static constexpr uint8_t RX8025T_REG_FLAG = 0x0E; +static constexpr uint8_t RX8025T_FLAG_VDET = 0x01; +static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; + +static const char *const TAG = "rx8025t"; + +constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } +constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } + +void RX8025TComponent::setup() { + uint8_t flags; + if (!this->read_flags_(&flags)) { + this->mark_failed(); + return; + } + + if (flags & RX8025T_FLAG_VLF) { + ESP_LOGW(TAG, "VLF flag is set - Loss of oscillator detected. Time may be invalid."); + } +} + +void RX8025TComponent::update() { this->read_time(); } + +void RX8025TComponent::dump_config() { + ESP_LOGCONFIG(TAG, "RX8025T:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + time::RealTimeClock::dump_config(); +} + +bool RX8025TComponent::read_flags_(uint8_t *flags) { + if (!this->read_byte(RX8025T_REG_FLAG, flags)) { + ESP_LOGE(TAG, "Can't read flag register."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + return true; +} + +void RX8025TComponent::read_time() { + uint8_t flags; + if (!this->read_flags_(&flags)) { + return; + } + + uint8_t date[7]; + if (!this->read_bytes(RX8025T_REG_SEC, date, sizeof(date))) { + ESP_LOGE(TAG, "Can't read I2C data."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + if (flags & RX8025T_FLAG_VLF) { + ESP_LOGW(TAG, "VLF flag is set - time data may be invalid, not syncing to system clock."); + return; + } + + ESPTime rtc_time{ + .second = bcd2dec(date[0] & 0x7f), + .minute = bcd2dec(date[1] & 0x7f), + .hour = bcd2dec(date[2] & 0x3f), + .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), + .day_of_month = bcd2dec(date[4] & 0x3f), + .day_of_year = 1, + .month = bcd2dec(date[5] & 0x1f), + .year = static_cast(bcd2dec(date[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d VDET:%s", rtc_time.year, rtc_time.month, + rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, ONOFF(flags & RX8025T_FLAG_VDET)); + this->synchronize_epoch_(rtc_time.timestamp); +} + +void RX8025TComponent::write_time() { + auto now = this->utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + + uint8_t buff[7]; + buff[0] = dec2bcd(now.second); + buff[1] = dec2bcd(now.minute); + buff[2] = dec2bcd(now.hour); + buff[3] = 1 << (now.day_of_week - 1); + buff[4] = dec2bcd(now.day_of_month); + buff[5] = dec2bcd(now.month); + buff[6] = dec2bcd(now.year % 100); + if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { + ESP_LOGE(TAG, "Can't write I2C data."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); + + // Clear VLF and VDET flags via read-modify-write of flag register + uint8_t flags; + if (!this->read_flags_(&flags)) { + return; + } + flags &= ~(RX8025T_FLAG_VLF | RX8025T_FLAG_VDET); + if (!this->write_byte(RX8025T_REG_FLAG, flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); +} + +} // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/rx8025t.h b/esphome/components/rx8025t/rx8025t.h new file mode 100644 index 00000000000..9c53fe6971d --- /dev/null +++ b/esphome/components/rx8025t/rx8025t.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8025t { + +class RX8025TComponent : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool read_flags_(uint8_t *flags); +}; + +} // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/time.py b/esphome/components/rx8025t/time.py new file mode 100644 index 00000000000..e8ffe772ab7 --- /dev/null +++ b/esphome/components/rx8025t/time.py @@ -0,0 +1,42 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +rx8025t_ns = cg.esphome_ns.namespace("rx8025t") +RX8025TComponent = rx8025t_ns.class_( + "RX8025TComponent", time.RealTimeClock, i2c.I2CDevice +) +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(RX8025TComponent), + } +).extend(i2c.i2c_device_schema(0x32)) + + +for _name, _call in ( + ("rx8025t.write_time", "write_time()"), + ("rx8025t.read_time", "read_time()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8025TComponent), + } + ), + automation.ApplyCall(_call), + ) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 0c738a9b78c..2ab3a5ed2b6 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -18,14 +18,4 @@ class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice void stop_(bool stop); }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts... x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts... x) override { this->parent_->read_time(); } -}; - } // namespace esphome::rx8130 diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 40d10e9f6b4..2082c487593 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -3,16 +3,12 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] rx8130_ns = cg.esphome_ns.namespace("rx8130") RX8130Component = rx8130_ns.class_("RX8130Component", time.RealTimeClock, i2c.I2CDevice) -WriteAction = rx8130_ns.class_("WriteAction", automation.Action) -ReadAction = rx8130_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -22,46 +18,19 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x32)) -@automation.register_action( - "rx8130.write_time", - WriteAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +RX8130_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } ) -async def rx8130_write_time_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]) - return var - -@automation.register_action( - "rx8130.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +automation.register_apply_action( + "rx8130.write_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) + +automation.register_apply_action( + "rx8130.read_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("read_time()") ) -async def rx8130_read_time_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]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 9bc8a263c82..5d03ed70a83 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,8 +10,8 @@ from esphome.const import ( CONF_STORAGE, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import RawExpression from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -22,7 +22,6 @@ CONF_ON_SAFE_MODE = "on_safe_mode" safe_mode_ns = cg.esphome_ns.namespace("safe_mode") SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) -MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) def _remove_id_if_disabled(value: ConfigType) -> ConfigType: @@ -53,26 +52,15 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "safe_mode.mark_successful", - MarkSuccessfulAction, cv.Schema( { cv.GenerateID(): cv.use_id(SafeModeComponent), } ), - synchronous=True, + automation.ApplyCall("mark_successful()"), ) -async def safe_mode_mark_successful_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(parent)) - return var _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h deleted file mode 100644 index e2858dff341..00000000000 --- a/esphome/components/safe_mode/automation.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include "esphome/core/automation.h" -#include "safe_mode.h" - -namespace esphome::safe_mode { - -template class MarkSuccessfulAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->mark_successful(); } -}; - -} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 0633c92a789..903d9eb79fd 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,6 +17,9 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: + // User provided, not "= default": `new(p) SafeModeComponent()` would zero-fill .bss that is already zero. + SafeModeComponent() {} + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h deleted file mode 100644 index a816ae1f260..00000000000 --- a/esphome/components/scd30/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd30.h" - -namespace esphome::scd30 { - -template -class ForceRecalibrationWithReference final : public Action, public Parented { - public: - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->force_recalibration_with_reference(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -} // namespace esphome::scd30 diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 37789100f72..f3f4ede8c93 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -22,8 +22,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -34,11 +32,6 @@ SCD30Component = scd30_ns.class_( "SCD30Component", cg.Component, sensirion_common.SensirionI2CDevice ) -# Actions -ForceRecalibrationWithReference = scd30_ns.class_( - "ForceRecalibrationWithReference", automation.Action -) - CONFIG_SCHEMA = ( cv.Schema( { @@ -106,22 +99,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_sensor(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) -@automation.register_action( +automation.register_apply_action( "scd30.force_recalibration_with_reference", - ForceRecalibrationWithReference, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SCD30Component), @@ -131,16 +116,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_VALUE, ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "force_recalibration_with_reference", cg.uint16), ) -async def scd30_force_recalibration_with_reference_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]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h deleted file mode 100644 index 4746c0c879c..00000000000 --- a/esphome/components/scd4x/automation.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd4x.h" - -namespace esphome::scd4x { - -template -class PerformForcedCalibrationAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->perform_forced_calibration(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index af3ff3a7af7..71be6e43b16 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@sjtrny", "@martgras"] @@ -47,12 +45,6 @@ MEASUREMENT_MODE_OPTIONS = { } -# Actions -PerformForcedCalibrationAction = scd4x_ns.class_( - "PerformForcedCalibrationAction", automation.Action -) -FactoryResetAction = scd4x_ns.class_("FactoryResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -140,23 +132,11 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "scd4x.perform_forced_calibration", - PerformForcedCalibrationAction, SCD4X_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VALUE, "perform_forced_calibration", cg.uint16), ) -async def scd4x_frc_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]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(template_)) - return var SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( @@ -166,18 +146,8 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "scd4x.factory_reset", - FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("factory_reset()"), ) -async def scd4x_reset_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]) - return var diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index e92850fd63a..2c79e9ed0a1 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -9,9 +9,7 @@ CODEOWNERS = ["@esphome/core"] script_ns = cg.esphome_ns.namespace("script") Script = script_ns.class_("Script", automation.Trigger.template()) ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) -ScriptStopAction = script_ns.class_("ScriptStopAction", automation.Action) ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component) -IsRunningCondition = script_ns.class_("IsRunningCondition", automation.Condition) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component) @@ -207,16 +205,11 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_apply_action( "script.stop", - ScriptStopAction, maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), - synchronous=True, + automation.ApplyCall("stop()"), ) -async def script_stop_action_to_code(config, action_id, template_arg, args): - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -233,12 +226,8 @@ async def script_wait_action_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( +automation.register_apply_condition( "script.is_running", - IsRunningCondition, automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + "is_running()", ) -async def script_is_running_to_code(config, condition_id, template_arg, args): - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 61bca5bc281..bfe96e1b23a 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -5,14 +5,8 @@ namespace esphome::script { static const char *const TAG = "script"; -#ifdef USE_STORE_LOG_STR_IN_FLASH -void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { +void ScriptLogger::esp_log_(int level, int line, ProgmemStr format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } -#else -void ScriptLogger::esp_log_(int level, int line, const char *format, const char *param) { - esp_log_printf_(level, TAG, line, format, param); -} -#endif } // namespace esphome::script diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 63d0ff7cb3c..b9ea75e0bcf 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -8,28 +8,19 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::script { class ScriptLogger { protected: -#ifdef USE_STORE_LOG_STR_IN_FLASH - void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { + void esp_logw_(int line, ProgmemStr format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } - void esp_logd_(int line, const __FlashStringHelper *format, const char *param) { + void esp_logd_(int line, ProgmemStr format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); } - void esp_log_(int level, int line, const __FlashStringHelper *format, const char *param); -#else - void esp_logw_(int line, const char *format, const char *param) { - esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); - } - void esp_logd_(int line, const char *format, const char *param) { - esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); - } - void esp_log_(int level, int line, const char *format, const char *param); -#endif + void esp_log_(int level, int line, ProgmemStr format, const char *param); }; /// The abstract base class for all script types. @@ -266,26 +257,6 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction final : public Action { - public: - ScriptStopAction(C *script) : script_(script) {} - - void play(const Ts &...x) override { this->script_->stop(); } - - protected: - C *script_; -}; - -template class IsRunningCondition final : public Condition { - public: - explicit IsRunningCondition(C *parent) : parent_(parent) {} - - bool check(const Ts &...x) override { return this->parent_->is_running(); } - - protected: - C *parent_; -}; - /** Wait for a script to finish before continuing. * * Uses queue-based storage to safely handle concurrent executions. diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 03fc086021a..a764b74581f 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || this->is_point_clipped(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8d7e981d933..74c56acf120 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -160,29 +160,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_TOTAL_POWER in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) - cg.add(var.set_total_power_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_IMPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_ACTIVE_ENERGY]) - cg.add(var.set_import_active_energy_sensor(sens)) - - if CONF_EXPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_ACTIVE_ENERGY]) - cg.add(var.set_export_active_energy_sensor(sens)) - - if CONF_IMPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_REACTIVE_ENERGY]) - cg.add(var.set_import_reactive_energy_sensor(sens)) - - if CONF_EXPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_REACTIVE_ENERGY]) - cg.add(var.set_export_reactive_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 1c222e5e803..dfc7857266d 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -106,8 +106,6 @@ void SDS011Component::loop() { } } -void SDS011Component::set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } - void SDS011Component::sds011_write_command_(const uint8_t *command_data) { this->write_byte(SDS011_MSG_HEAD); this->write_byte(SDS011_COMMAND_ID_REQUEST); diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 4f4571ab693..0a896cdc4cd 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -12,7 +12,7 @@ class SDS011Component final : public Component, public uart::UARTDevice { SDS011Component() = default; /// Manually set the rx-only mode. Defaults to false. - void set_rx_mode_only(bool rx_mode_only); + void set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5_sensor) { pm_2_5_sensor_ = pm_2_5_sensor; } void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } diff --git a/esphome/components/seeed_mr24hpc1/binary_sensor.py b/esphome/components/seeed_mr24hpc1/binary_sensor.py index 121eb2b4b36..98f3995ed98 100644 --- a/esphome/components/seeed_mr24hpc1/binary_sensor.py +++ b/esphome/components/seeed_mr24hpc1/binary_sensor.py @@ -15,7 +15,6 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(mr24hpc1_component.set_has_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button/__init__.py index 3386118bcff..58c62652b09 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button/__init__.py @@ -33,12 +33,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_restart_button(b)) - if custom_set_end_config := config.get(CONF_CUSTOM_SET_END): - b = await button.new_button(custom_set_end_config) - await cg.register_parented(b, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_custom_set_end_button(b)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons(CONF_CUSTOM_SET_END, hub.set_custom_set_end_button) diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number/__init__.py index d01618b0e60..0dc6d07a96d 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number/__init__.py @@ -65,67 +65,46 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if sensitivity_config := config.get(CONF_SENSITIVITY): - n = await number.new_number( - sensitivity_config, - min_value=0, - max_value=3, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_sensitivity_number(n)) - if custom_mode_config := config.get(CONF_CUSTOM_MODE): - n = await number.new_number( - custom_mode_config, - min_value=0, - max_value=4, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_custom_mode_number(n)) - if existence_threshold_config := config.get(CONF_EXISTENCE_THRESHOLD): - n = await number.new_number( - existence_threshold_config, - min_value=0, - max_value=250, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_existence_threshold_number(n)) - if motion_threshold_config := config.get(CONF_MOTION_THRESHOLD): - n = await number.new_number( - motion_threshold_config, - min_value=0, - max_value=250, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_threshold_number(n)) - if motion_trigger_config := config.get(CONF_MOTION_TRIGGER): - n = await number.new_number( - motion_trigger_config, - min_value=0, - max_value=150, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_trigger_number(n)) - if motion_to_rest_config := config.get(CONF_MOTION_TO_REST): - n = await number.new_number( - motion_to_rest_config, - min_value=0, - max_value=3000, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_to_rest_number(n)) - if custom_unman_time_config := config.get(CONF_CUSTOM_UNMAN_TIME): - n = await number.new_number( - custom_unman_time_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_custom_unman_time_number(n)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_SENSITIVITY, hub.set_sensitivity_number, min_value=0, max_value=3, step=1 + ) + await numbers( + CONF_CUSTOM_MODE, hub.set_custom_mode_number, min_value=0, max_value=4, step=1 + ) + await numbers( + CONF_EXISTENCE_THRESHOLD, + hub.set_existence_threshold_number, + min_value=0, + max_value=250, + step=1, + ) + await numbers( + CONF_MOTION_THRESHOLD, + hub.set_motion_threshold_number, + min_value=0, + max_value=250, + step=1, + ) + await numbers( + CONF_MOTION_TRIGGER, + hub.set_motion_trigger_number, + min_value=0, + max_value=150, + step=1, + ) + await numbers( + CONF_MOTION_TO_REST, + hub.set_motion_to_rest_number, + min_value=0, + max_value=3000, + step=1, + ) + await numbers( + CONF_CUSTOM_UNMAN_TIME, + hub.set_custom_unman_time_number, + min_value=0, + max_value=3600, + step=1, + ) diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select/__init__.py index 9d46dee6f69..208040e9e05 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select/__init__.py @@ -40,64 +40,57 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if scenemode_config := config.get(CONF_SCENE_MODE): - s = await select.new_select( - scenemode_config, - options=["None", "Living Room", "Bedroom", "Washroom", "Area Detection"], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_scene_mode_select(s)) - if unmantime_config := config.get(CONF_UNMAN_TIME): - s = await select.new_select( - unmantime_config, - options=[ - "None", - "10s", - "30s", - "1min", - "2min", - "5min", - "10min", - "30min", - "60min", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_unman_time_select(s)) - if existence_boundary_config := config.get(CONF_EXISTENCE_BOUNDARY): - s = await select.new_select( - existence_boundary_config, - options=[ - "0.5m", - "1.0m", - "1.5m", - "2.0m", - "2.5m", - "3.0m", - "3.5m", - "4.0m", - "4.5m", - "5.0m", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_existence_boundary_select(s)) - if motion_boundary_config := config.get(CONF_MOTION_BOUNDARY): - s = await select.new_select( - motion_boundary_config, - options=[ - "0.5m", - "1.0m", - "1.5m", - "2.0m", - "2.5m", - "3.0m", - "3.5m", - "4.0m", - "4.5m", - "5.0m", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_motion_boundary_select(s)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_SCENE_MODE, + hub.set_scene_mode_select, + options=["None", "Living Room", "Bedroom", "Washroom", "Area Detection"], + ) + await selects( + CONF_UNMAN_TIME, + hub.set_unman_time_select, + options=[ + "None", + "10s", + "30s", + "1min", + "2min", + "5min", + "10min", + "30min", + "60min", + ], + ) + await selects( + CONF_EXISTENCE_BOUNDARY, + hub.set_existence_boundary_select, + options=[ + "0.5m", + "1.0m", + "1.5m", + "2.0m", + "2.5m", + "3.0m", + "3.5m", + "4.0m", + "4.5m", + "5.0m", + ], + ) + await selects( + CONF_MOTION_BOUNDARY, + hub.set_motion_boundary_select, + options=[ + "0.5m", + "1.0m", + "1.5m", + "2.0m", + "2.5m", + "3.0m", + "3.5m", + "4.0m", + "4.5m", + "5.0m", + ], + ) diff --git a/esphome/components/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index 36ee2c0087e..e2e45656ce8 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -62,27 +62,18 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if custompresenceofdetection_config := config.get( - CONF_CUSTOM_PRESENCE_OF_DETECTION - ): - sens = await sensor.new_sensor(custompresenceofdetection_config) - cg.add(mr24hpc1_component.set_custom_presence_of_detection_sensor(sens)) - if movementsigns_config := config.get(CONF_MOVEMENT_SIGNS): - sens = await sensor.new_sensor(movementsigns_config) - cg.add(mr24hpc1_component.set_movement_signs_sensor(sens)) - if custommotiondistance_config := config.get(CONF_CUSTOM_MOTION_DISTANCE): - sens = await sensor.new_sensor(custommotiondistance_config) - cg.add(mr24hpc1_component.set_custom_motion_distance_sensor(sens)) - if customspatialstaticvalue_config := config.get(CONF_CUSTOM_SPATIAL_STATIC_VALUE): - sens = await sensor.new_sensor(customspatialstaticvalue_config) - cg.add(mr24hpc1_component.set_custom_spatial_static_value_sensor(sens)) - if customspatialmotionvalue_config := config.get(CONF_CUSTOM_SPATIAL_MOTION_VALUE): - sens = await sensor.new_sensor(customspatialmotionvalue_config) - cg.add(mr24hpc1_component.set_custom_spatial_motion_value_sensor(sens)) - if custommotionspeed_config := config.get(CONF_CUSTOM_MOTION_SPEED): - sens = await sensor.new_sensor(custommotionspeed_config) - cg.add(mr24hpc1_component.set_custom_motion_speed_sensor(sens)) - if custommodenum_config := config.get(CONF_CUSTOM_MODE_NUM): - sens = await sensor.new_sensor(custommodenum_config) - cg.add(mr24hpc1_component.set_custom_mode_num_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + sensors = sensor.sub_sensors(config) + await sensors( + CONF_CUSTOM_PRESENCE_OF_DETECTION, hub.set_custom_presence_of_detection_sensor + ) + await sensors(CONF_MOVEMENT_SIGNS, hub.set_movement_signs_sensor) + await sensors(CONF_CUSTOM_MOTION_DISTANCE, hub.set_custom_motion_distance_sensor) + await sensors( + CONF_CUSTOM_SPATIAL_STATIC_VALUE, hub.set_custom_spatial_static_value_sensor + ) + await sensors( + CONF_CUSTOM_SPATIAL_MOTION_VALUE, hub.set_custom_spatial_motion_value_sensor + ) + await sensors(CONF_CUSTOM_MOTION_SPEED, hub.set_custom_motion_speed_sensor) + await sensors(CONF_CUSTOM_MODE_NUM, hub.set_custom_mode_num_sensor) diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch/__init__.py index f9588d783e3..76e8a2089bc 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch/__init__.py @@ -24,8 +24,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if underlying_open_function_config := config.get(CONF_UNDERLYING_OPEN_FUNCTION): - s = await switch.new_switch(underlying_open_function_config) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_underlying_open_function_switch(s)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_UNDERLYING_OPEN_FUNCTION, hub.set_underlying_open_function_switch + ) diff --git a/esphome/components/seeed_mr24hpc1/text_sensor.py b/esphome/components/seeed_mr24hpc1/text_sensor.py index 8f284cb20ab..2f926b1eda9 100644 --- a/esphome/components/seeed_mr24hpc1/text_sensor.py +++ b/esphome/components/seeed_mr24hpc1/text_sensor.py @@ -49,28 +49,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if heartbeat_config := config.get(CONF_HEART_BEAT): - sens = await text_sensor.new_text_sensor(heartbeat_config) - cg.add(mr24hpc1_component.set_heartbeat_state_text_sensor(sens)) - if productmodel_config := config.get(CONF_PRODUCT_MODEL): - sens = await text_sensor.new_text_sensor(productmodel_config) - cg.add(mr24hpc1_component.set_product_model_text_sensor(sens)) - if productid_config := config.get(CONF_PRODUCT_ID): - sens = await text_sensor.new_text_sensor(productid_config) - cg.add(mr24hpc1_component.set_product_id_text_sensor(sens)) - if hardwaremodel_config := config.get(CONF_HARDWARE_MODEL): - sens = await text_sensor.new_text_sensor(hardwaremodel_config) - cg.add(mr24hpc1_component.set_hardware_model_text_sensor(sens)) - if firwareversion_config := config.get(CONF_HARDWARE_VERSION): - sens = await text_sensor.new_text_sensor(firwareversion_config) - cg.add(mr24hpc1_component.set_firware_version_text_sensor(sens)) - if keepaway_config := config.get(CONF_KEEP_AWAY): - sens = await text_sensor.new_text_sensor(keepaway_config) - cg.add(mr24hpc1_component.set_keep_away_text_sensor(sens)) - if motionstatus_config := config.get(CONF_MOTION_STATUS): - sens = await text_sensor.new_text_sensor(motionstatus_config) - cg.add(mr24hpc1_component.set_motion_status_text_sensor(sens)) - if custommodeend_config := config.get(CONF_CUSTOM_MODE_END): - sens = await text_sensor.new_text_sensor(custommodeend_config) - cg.add(mr24hpc1_component.set_custom_mode_end_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_HEART_BEAT, hub.set_heartbeat_state_text_sensor) + await text_sensors(CONF_PRODUCT_MODEL, hub.set_product_model_text_sensor) + await text_sensors(CONF_PRODUCT_ID, hub.set_product_id_text_sensor) + await text_sensors(CONF_HARDWARE_MODEL, hub.set_hardware_model_text_sensor) + await text_sensors(CONF_HARDWARE_VERSION, hub.set_firware_version_text_sensor) + await text_sensors(CONF_KEEP_AWAY, hub.set_keep_away_text_sensor) + await text_sensors(CONF_MOTION_STATUS, hub.set_motion_status_text_sensor) + await text_sensors(CONF_CUSTOM_MODE_END, hub.set_custom_mode_end_text_sensor) diff --git a/esphome/components/seeed_mr60bha2/binary_sensor.py b/esphome/components/seeed_mr60bha2/binary_sensor.py index 4130bac224a..b009de8381a 100644 --- a/esphome/components/seeed_mr60bha2/binary_sensor.py +++ b/esphome/components/seeed_mr60bha2/binary_sensor.py @@ -17,8 +17,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) + hub = await cg.get_variable(config[CONF_MR60BHA2_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(mr60bha2_component.set_has_target_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/seeed_mr60bha2/sensor.py b/esphome/components/seeed_mr60bha2/sensor.py index a2f41a90a81..d815aa1a8c3 100644 --- a/esphome/components/seeed_mr60bha2/sensor.py +++ b/esphome/components/seeed_mr60bha2/sensor.py @@ -51,16 +51,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) - if breath_rate_config := config.get(CONF_BREATH_RATE): - sens = await sensor.new_sensor(breath_rate_config) - cg.add(mr60bha2_component.set_breath_rate_sensor(sens)) - if heart_rate_config := config.get(CONF_HEART_RATE): - sens = await sensor.new_sensor(heart_rate_config) - cg.add(mr60bha2_component.set_heart_rate_sensor(sens)) - if distance_config := config.get(CONF_DISTANCE): - sens = await sensor.new_sensor(distance_config) - cg.add(mr60bha2_component.set_distance_sensor(sens)) - if num_targets_config := config.get(CONF_NUM_TARGETS): - sens = await sensor.new_sensor(num_targets_config) - cg.add(mr60bha2_component.set_num_targets_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR60BHA2_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BREATH_RATE, hub.set_breath_rate_sensor) + await sensors(CONF_HEART_RATE, hub.set_heart_rate_sensor) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_NUM_TARGETS, hub.set_num_targets_sensor) diff --git a/esphome/components/seeed_mr60fda2/binary_sensor.py b/esphome/components/seeed_mr60fda2/binary_sensor.py index 63bd02acd01..e1dc4289972 100644 --- a/esphome/components/seeed_mr60fda2/binary_sensor.py +++ b/esphome/components/seeed_mr60fda2/binary_sensor.py @@ -23,12 +23,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if people_exist_config := config.get(CONF_PEOPLE_EXIST): - sens = await binary_sensor.new_binary_sensor(people_exist_config) - cg.add(mr60fda2_component.set_people_exist_binary_sensor(sens)) - - if is_fall_config := config.get(CONF_FALL_DETECTED): - sens = await binary_sensor.new_binary_sensor(is_fall_config) - cg.add(mr60fda2_component.set_fall_detected_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PEOPLE_EXIST, hub.set_people_exist_binary_sensor) + await binary_sensors(CONF_FALL_DETECTED, hub.set_fall_detected_binary_sensor) diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button/__init__.py index 82f0fc9aeaf..582c72bebe9 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button/__init__.py @@ -35,12 +35,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if get_radar_parameters_config := config.get(CONF_GET_RADAR_PARAMETERS): - b = await button.new_button(get_radar_parameters_config) - await cg.register_parented(b, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_get_radar_parameters_button(b)) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_factory_reset_button(b)) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_GET_RADAR_PARAMETERS, hub.set_get_radar_parameters_button) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select/__init__.py index 6d8864455f0..0b3359b71cd 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select/__init__.py @@ -35,25 +35,16 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if install_height_config := config.get(CONF_INSTALL_HEIGHT): - s = await select.new_select( - install_height_config, - options=["2.4m", "2.5m", "2.6m", "2.7m", "2.8m", "2.9m", "3.0m"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_install_height_select(s)) - if height_threshold_config := config.get(CONF_HEIGHT_THRESHOLD): - s = await select.new_select( - height_threshold_config, - options=["0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_height_threshold_select(s)) - if sensitivity_config := config.get(CONF_SENSITIVITY): - s = await select.new_select( - sensitivity_config, - options=["1", "2", "3"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_sensitivity_select(s)) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_INSTALL_HEIGHT, + hub.set_install_height_select, + options=["2.4m", "2.5m", "2.6m", "2.7m", "2.8m", "2.9m", "3.0m"], + ) + await selects( + CONF_HEIGHT_THRESHOLD, + hub.set_height_threshold_select, + options=["0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"], + ) + await selects(CONF_SENSITIVITY, hub.set_sensitivity_select, options=["1", "2", "3"]) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 0d4e0fafc50..04aef00d46b 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -27,6 +27,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_VOLT_AMPS_REACTIVE, @@ -44,9 +47,6 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" -UNIT_KILOWATT_HOURS = "kWh" -UNIT_KILOVOLT_AMPS_HOURS = "kVAh" -UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index f561c030a49..ae78e729e6e 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -20,12 +20,14 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass, TemplateArguments +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArguments from esphome.cpp_types import global_ns +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -40,11 +42,6 @@ SelectStateTrigger = select_ns.class_( automation.Trigger.template(cg.StringRef, cg.size_t), ) -# Actions -SelectSetAction = select_ns.class_("SelectSetAction", automation.Action) -SelectSetIndexAction = select_ns.class_("SelectSetIndexAction", automation.Action) -SelectOperationAction = select_ns.class_("SelectOperationAction", automation.Action) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -128,6 +125,13 @@ async def new_select(config, *args, options: list[str]): return var +def sub_selects( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_select.""" + return SubEntities(new_select, config, parent) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(select_ns.using) @@ -140,40 +144,32 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "select.set", - SelectSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField( + CONF_OPTION, + "set_option", + cg.std_string, + const_fn=automation.literal_with_length, + ), + call="make_call", ) -async def select_set_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_OPTION], args, cg.std_string) - cg.add(var.set_option(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "select.set_index", - SelectSetIndexAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_INDEX): cv.templatable(cv.positive_int), } ), - synchronous=True, + automation.ApplyField(CONF_INDEX, "set_index", cg.size_t), + call="make_call", ) -async def select_set_index_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.size_t) - cg.add(var.set_index(template_)) - return var @automation.register_condition( @@ -211,9 +207,8 @@ async def select_is_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg, paren, arg) -@automation.register_action( +automation.register_apply_action( "select.operation", - SelectOperationAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPERATION): cv.templatable( @@ -222,75 +217,28 @@ async def select_is_to_code(config, condition_id, template_arg, args): cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_OPERATION, "with_operation", SelectOperation), + automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_), + call="make_call", ) -@automation.register_action( - "select.next", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="NEXT"): cv.one_of("NEXT", upper=True), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.previous", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="PREVIOUS"): cv.one_of( - "PREVIOUS", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.first", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="FIRST"): cv.one_of("FIRST", upper=True), - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.last", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="LAST"): cv.one_of("LAST", upper=True), - } - ) - ), - synchronous=True, -) -async def select_operation_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (operation := config.get(CONF_OPERATION)) is not None: - op_ = await cg.templatable(operation, args, SelectOperation) - cg.add(var.set_operation(op_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable( - SELECT_OPERATION_OPTIONS[mode], args, SelectOperation - ) - cg.add(var.set_operation(template_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - return var + +# The operation is fixed by the action name; CONF_MODE only stays accepted in the config. +for _name, _mode, _cycle in ( + ("select.next", "NEXT", True), + ("select.previous", "PREVIOUS", True), + ("select.first", "FIRST", False), + ("select.last", "LAST", False), +): + _schema = {cv.Optional(CONF_MODE, default=_mode): cv.one_of(_mode, upper=True)} + _fields = [ + automation.ApplyCall(f"with_operation({SELECT_OPERATION_OPTIONS[_mode]})") + ] + if _cycle: + _schema[cv.Optional(CONF_CYCLE, default=True)] = cv.boolean + _fields.append(automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_)) + automation.register_apply_action( + _name, + automation.maybe_simple_id(OPERATION_BASE_SCHEMA.extend(_schema)), + *_fields, + call="make_call", + ) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 8e5da893ad4..c2dc752ca5a 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -17,55 +17,6 @@ class SelectStateTrigger final : public Trigger { Select *parent_; }; -template class SelectSetAction final : public Action { - public: - explicit SelectSetAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(std::string, option) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.set_option(this->option_.value(x...)); - call.perform(); - } - - protected: - Select *select_; -}; - -template class SelectSetIndexAction final : public Action { - public: - explicit SelectSetIndexAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(size_t, index) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.set_index(this->index_.value(x...)); - call.perform(); - } - - protected: - Select *select_; -}; - -template class SelectOperationAction final : public Action { - public: - explicit SelectOperationAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(bool, cycle) - TEMPLATABLE_VALUE(SelectOperation, operation) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Select *select_; -}; - template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h deleted file mode 100644 index 21d938c4fea..00000000000 --- a/esphome/components/sen5x/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "sen5x.h" - -namespace esphome::sen5x { - -template class StartFanAction final : public Action { - public: - explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} - - void play(const Ts &...x) override { this->sen5x_->start_fan_cleaning(); } - - protected: - SEN5XComponent *sen5x_; -}; - -} // namespace esphome::sen5x diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index e86c8bf899e..7bfce0f26a5 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -43,8 +43,6 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -62,9 +60,6 @@ CONF_ACCELERATION_MODE = "acceleration_mode" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" -# Actions -StartFanAction = sen5x_ns.class_("StartFanAction", automation.Action) - ACCELERATION_MODES = { "low": RhtAccelerationMode.LOW_ACCELERATION, "medium": RhtAccelerationMode.MEDIUM_ACCELERATION, @@ -282,17 +277,8 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "sen5x.start_fan_autoclean", - StartFanAction, SEN5X_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("start_fan_cleaning()"), ) -async def sen54_fan_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 570fd3faddd..42dae464ede 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -2,20 +2,27 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg -from esphome.components import esp32, network, psram, socket, wifi +from esphome.components import esp32, mdns, network, psram, socket, wifi +from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_ESPHOME, CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MDNS, + CONF_MODEL, + CONF_NAME, + CONF_PROJECT, CONF_SAMPLE_RATE, CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_VERSION, CONF_WIDTH, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType # mdns for autodiscovery @@ -27,9 +34,17 @@ DOMAIN = "sendspin" CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" +CONF_FIRMWARE_VERSION = "firmware_version" + +# An empty device information string would be sent to the server as an empty value rather than +# falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps +# the length prefix of a protobuf string field to a single byte, matching `esphome: project:`. +DEVICE_INFO_STRING = cv.All(cv.string_strict, cv.Length(min=1), cv.ByteLength(max=127)) + CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +CONF_CODECS = "codecs" # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -44,6 +59,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +CODEC_FLAC = "flac" +CODEC_OPUS = "opus" +CODEC_PCM = "pcm" + +CODECS = { + CODEC_FLAC: CODEC_FORMAT_FLAC, + CODEC_OPUS: CODEC_FORMAT_OPUS, + CODEC_PCM: CODEC_FORMAT_PCM, +} + +# Opus only supports 48 kHz audio, so it is left out of the default list at other rates. +DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM] +OPUS_SAMPLE_RATE = 48000 + SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") @@ -80,13 +109,6 @@ SendspinHub = sendspin_ns.class_( ) -SendspinSwitchCommandAction = sendspin_ns.class_( - "SendspinSwitchCommandAction", - automation.Action, - cg.Parented.template(SendspinHub), -) - - @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -183,6 +205,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SendspinHub), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, + cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, + cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, + cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, } ), cv.only_on_esp32, @@ -208,21 +233,11 @@ SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "sendspin.switch", - SendspinSwitchCommandAction, SENDSPIN_SIMPLE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("switch_client()"), ) -async def sendspin_switch_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]) - return var async def to_code(config: ConfigType) -> None: @@ -233,11 +248,32 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + # Device information for the server's client/hello message. Falls back to the project + # information, which is written as `manufacturer.model`. Anything still unset keeps the + # default the hub itself applies: the ESPHome name and version. + project = CORE.config[CONF_ESPHOME].get(CONF_PROJECT, {}) + project_manufacturer, _, project_model = project.get(CONF_NAME, "").partition(".") + for value, setter in ( + (config.get(CONF_MANUFACTURER) or project_manufacturer, var.set_manufacturer), + (config.get(CONF_MODEL) or project_model, var.set_model), + ( + config.get(CONF_FIRMWARE_VERSION) or project.get(CONF_VERSION), + var.set_firmware_version, + ), + ): + if value: + cg.add(setter(value)) + # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") cg.add_define("USE_SENDSPIN", True) # for MDNS + # Service starts disabled and the hub enables it; always advertised where unsupported + if mdns.request_service_enable_disable(): + mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + cg.add(var.set_mdns(mdns_var)) + data = _get_data() # The color role is not yet wired up in ESPHome; disable it in the library for now. @@ -286,16 +322,13 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) - # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate - # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + # Configures the player role. Each configured codec is advertised for 16 bits per sample + # mono and stereo at the configured sample rate. The order is a preference order, both for + # the codecs themselves and for stereo over mono. player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - # OPUS only supports 48 kHz audio - codecs = [CODEC_FORMAT_FLAC] - if sample_rate == 48000: - codecs.append(CODEC_FORMAT_OPUS) - codecs.append(CODEC_FORMAT_PCM) + codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h deleted file mode 100644 index 0b408b12350..00000000000 --- a/esphome/components/sendspin/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/core/automation.h" -#include "sendspin_hub.h" - -namespace esphome::sendspin_ { - -#ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { - // Clear any EXTERNAL_SOURCE state so the switch command is followed - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH); - } -}; -#endif // USE_SENDSPIN_CONTROLLER - -} // namespace esphome::sendspin_ - -#endif // USE_ESP32 diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 3c6c82b0091..e26e8204aaf 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -16,7 +16,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import ID -from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType from .. import ( @@ -198,16 +197,8 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -SendspinImageTransitionFinishedAction = sendspin_ns.class_( - "SendspinImageTransitionFinishedAction", - automation.Action, - cg.Parented.template(SendspinImageSlot), -) - - -@automation.register_action( +automation.register_apply_action( "sendspin.image.transition_finished", - SendspinImageTransitionFinishedAction, automation.maybe_simple_id( cv.Schema( { @@ -215,14 +206,5 @@ SendspinImageTransitionFinishedAction = sendspin_ns.class_( } ) ), - synchronous=True, + automation.ApplyCall("transition_finished()"), ) -async def sendspin_image_transition_finished_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h deleted file mode 100644 index 154e62a4b20..00000000000 --- a/esphome/components/sendspin/image/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) - -#include "esphome/core/automation.h" -#include "sendspin_image.h" - -namespace esphome::sendspin_ { - -template -class SendspinImageTransitionFinishedAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->transition_finished(); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index fe0bda6f421..59ead1bb539 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { // Ignore any commands sent before the media player is setup return; } + if (!this->parent_->is_client_running()) { + ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled"); + return; + } auto volume = call.get_volume(); if (volume.has_value()) { diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6af244d41f0..743d0bb62cf 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -8,16 +8,19 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TASK_STACK_IN_PSRAM, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_OPUS, + CODECS, + CONF_CODECS, CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + DEFAULT_CODECS, MEMORY_LOCATIONS, + OPUS_SAMPLE_RATE, SendspinHub, register_player_config, request_controller_support, @@ -36,23 +39,33 @@ SendspinMediaSource = sendspin_ns.class_( media_source.MediaSource, ) -EnableStaticDelayAdjustmentAction = sendspin_ns.class_( - "EnableStaticDelayAdjustmentAction", - automation.Action, - cg.Parented.template(SendspinMediaSource), -) -DisableStaticDelayAdjustmentAction = sendspin_ns.class_( - "DisableStaticDelayAdjustmentAction", - automation.Action, - cg.Parented.template(SendspinMediaSource), -) +def _resolve_codecs(config: ConfigType) -> ConfigType: + """Validate the codec preference list, filling in the default when it is not set.""" + sample_rate = config[CONF_SAMPLE_RATE] + if (codecs := config.get(CONF_CODECS)) is None: + config[CONF_CODECS] = [ + codec + for codec in DEFAULT_CODECS + if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE + ] + return config + + if len(set(codecs)) != len(codecs): + raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS]) + if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE: + raise cv.Invalid( + f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", + path=[CONF_CODECS], + ) + return config def _register(config: ConfigType) -> ConfigType: request_controller_support() register_player_config( { + CONF_CODECS: config[CONF_CODECS], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], @@ -85,9 +98,13 @@ CONFIG_SCHEMA = cv.All( min=16000, max=96000 ), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), + cv.Optional(CONF_CODECS): cv.All( + cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1) + ), } ), cv.only_on_esp32, + _resolve_codecs, _register, ) @@ -113,25 +130,16 @@ SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id( ) ) - -@automation.register_action( - "sendspin.media_source.enable_static_delay_adjustment", - EnableStaticDelayAdjustmentAction, - SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sendspin.media_source.disable_static_delay_adjustment", - DisableStaticDelayAdjustmentAction, - SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, - synchronous=True, -) -async def sendspin_static_delay_adjustment_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]) - return var +for _name, _call in ( + ( + "sendspin.media_source.enable_static_delay_adjustment", + "set_static_delay_adjustable(true)", + ), + ( + "sendspin.media_source.disable_static_delay_adjustment", + "set_static_delay_adjustable(false)", + ), +): + automation.register_apply_action( + _name, SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h deleted file mode 100644 index f5c35f107af..00000000000 --- a/esphome/components/sendspin/media_source/automations.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) - -#include "esphome/core/automation.h" -#include "sendspin_media_source.h" - -namespace esphome::sendspin_ { - -template -class EnableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } -}; - -template -class DisableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 88ff234e831..c3fb1fe1cbd 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri. // THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) bool SendspinMediaSource::play_uri(const std::string &uri) { + // The queued request has been delivered, whatever the outcome, so the next stream start may request again + this->pending_start_ = false; if (!this->is_ready() || this->is_failed() || !this->has_listener()) { return false; } @@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { return false; } + if (!this->parent_->is_client_running()) { + ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str()); + return false; + } + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; @@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { } // Tell the orchestrator we're now playing so it routes audio output from us - this->pending_start_ = false; this->set_state_(media_source::MediaSourceState::PLAYING); return true; @@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { // THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + if (!this->parent_->is_client_running()) { + if (command == media_source::MediaSourceCommand::STOP) { + // Nothing is playing, so the orchestrator gets its pipeline back straight away + this->on_stream_end(); + } else { + ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled"); + } + return; + } switch (command) { case media_source::MediaSourceCommand::STOP: { if (!this->pending_start_) { diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 028491284a7..15d358d50fb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -62,22 +62,38 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start_server()) { - ESP_LOGE(TAG, "Failed to start Sendspin server"); - this->mark_failed(); - return; - } +#ifndef USE_SENDSPIN_SWITCH + this->enabled_ = true; +#endif } -void SendspinHub::loop() { this->client_->loop(); } +void SendspinHub::loop() { + if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && + !this->status_has_error()) { + if (!this->enabled_.value()) { + this->client_->stop(); + } else if (!this->client_->start()) { + this->status_set_error(LOG_STR("Failed to start Sendspin client")); + } + } + this->client_->loop(); + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + this->update_mdns_service_(); +#endif +} void SendspinHub::dump_config() { char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " Manufacturer: %s\n" + " Model: %s\n" + " Firmware version: %s\n" " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_)); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -92,25 +108,54 @@ void SendspinHub::dump_config() { #endif } +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::set_enabled(bool enabled) { + if (this->status_has_error()) { + ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", + enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + return; + } + this->enabled_ = enabled; +} + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +// THREAD CONTEXT: Main loop +void SendspinHub::update_mdns_service_() { + // Synced from loop() because mdns sets up after this hub and only builds its service list then. + if (!this->mdns_->is_ready()) { + return; + } + bool advertise = this->client_->is_started(); + if (advertise == this->mdns_advertised_) { + return; + } + // One attempt per change + this->mdns_advertised_ = advertise; + if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) { + ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + } +} +#endif + // --- Delegating methods --- // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::connect_to_server(const std::string &url) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->connect_to(url); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->disconnect(reason); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->update_state(state); } } @@ -127,15 +172,19 @@ const char *SendspinHub::get_client_id_into_buffer(std::spanmodel_ != nullptr ? this->model_ : App.get_name().c_str(); +} + sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); config.name = App.get_friendly_name(); - config.product_name = App.get_name(); - config.manufacturer = "ESPHome"; - config.software_version = ESPHOME_VERSION; + config.product_name = this->get_product_name_(); + config.manufacturer = this->manufacturer_; + config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; return config; @@ -225,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) { // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { - if (this->is_ready()) { + if (this->is_client_running()) { sendspin::ClientCommandControllerObject obj = { .command = command, .volume = volume, @@ -235,6 +284,13 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) +void SendspinHub::switch_client() { + // Clear any EXTERNAL_SOURCE state so the switch command is followed + this->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); +} + // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c50c3eb809..8ab70d61ef1 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,11 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/version.h" + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#include "esphome/components/mdns/mdns_component.h" +#endif #include #include @@ -92,7 +97,7 @@ class SendspinHub final : public Component, /// @brief Connects the underlying client to the given Sendspin server. /// - /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// No-op if the hub's client is not running (see is_client_running()). /// Must be called from the main loop thread. /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). void connect_to_server(const std::string &url); @@ -100,7 +105,7 @@ class SendspinHub final : public Component, /// @brief Disconnects the underlying client from the current server. /// /// Sends a `client/goodbye` message with the given reason before closing the connection. - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param reason Reason reported to the server: /// - `ANOTHER_SERVER`: client is switching to another server. /// - `SHUTDOWN`: client is shutting down. @@ -110,7 +115,7 @@ class SendspinHub final : public Component, /// @brief Updates the client's reported playback state on the server. /// - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param state New client state: /// - `SYNCHRONIZED`: client is synchronized and playing from the server. /// - `ERROR`: client encountered a playback error. @@ -125,6 +130,30 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or + /// stop. + /// + /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks + /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has + /// called this once. Must be called from the main loop thread. + void set_enabled(bool enabled); + + /// @brief Returns whether the Sendspin client is running. + bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + + /// @brief Sets the device information reported to the server in the `client/hello` message. + /// + /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the + /// lifetime of the hub. Only called for values the configuration overrides; anything left alone + /// keeps the default described on the member below. + void set_manufacturer(const char *manufacturer) { this->manufacturer_ = manufacturer; } + void set_model(const char *model) { this->model_ = model; } + void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +#endif + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -152,6 +181,9 @@ class SendspinHub final : public Component, void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); + /// @brief Sends the SWITCH controller command; exposed as the sendspin.switch action. + void switch_client(); + template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } @@ -187,10 +219,18 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// @brief Returns the product name reported to the server: the configured model, or the device name. + const char *get_product_name_() const; + /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. + void update_mdns_service_(); +#endif + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; @@ -268,6 +308,20 @@ class SendspinHub final : public Component, CallbackManager group_update_callbacks_{}; bool task_stack_in_psram_{false}; + + // Requested client state, applied from loop(). Empty until the switch restores its state. + std::optional enabled_; + + // Device information sent in the `client/hello` message. Defaults apply when neither the + // sendspin configuration nor the project information supplies a value. + const char *manufacturer_{"ESPHome"}; + const char *model_{nullptr}; // nullptr reports the device name instead + const char *firmware_version_{ESPHOME_VERSION}; + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + mdns::MDNSComponent *mdns_{nullptr}; + bool mdns_advertised_{false}; // Last state requested from mdns +#endif }; /// @brief Base class for all sendspin subcomponents. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py new file mode 100644 index 00000000000..63f5f7ad28b --- /dev/null +++ b/esphome/components/sendspin/switch/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) + +CONFIG_SCHEMA = cv.All( + switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + var = await switch.new_switch(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + cg.add_define("USE_SENDSPIN_SWITCH", True) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp new file mode 100644 index 00000000000..0bf029d4c77 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -0,0 +1,26 @@ +#include "sendspin_switch.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.switch"; + +void SendspinSwitch::setup() { + // The hub waits for this request, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinSwitch::write_state(bool state) { + this->parent_->set_enabled(state); + this->publish_state(state); +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h new file mode 100644 index 00000000000..253d952b220 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::sendspin_ { + +/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). +class SendspinSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 48154a53d95..b376f1f657b 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -37,54 +36,4 @@ class SenseAirComponent final : public PollingComponent, public uart::UARTDevice sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction final : public Action { - public: - SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->background_calibration(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirBackgroundCalibrationResultAction final : public Action { - public: - SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->background_calibration_result(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCEnableAction final : public Action { - public: - SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_enable(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCDisableAction final : public Action { - public: - SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_disable(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCGetPeriodAction final : public Action { - public: - SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_get_period(); } - - protected: - SenseAirComponent *senseair_; -}; - } // namespace esphome::senseair diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 82368a60d02..9445e5867c3 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -11,8 +11,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -21,21 +19,6 @@ senseair_ns = cg.esphome_ns.namespace("senseair") SenseAirComponent = senseair_ns.class_( "SenseAirComponent", cg.PollingComponent, uart.UARTDevice ) -SenseAirBackgroundCalibrationAction = senseair_ns.class_( - "SenseAirBackgroundCalibrationAction", automation.Action -) -SenseAirBackgroundCalibrationResultAction = senseair_ns.class_( - "SenseAirBackgroundCalibrationResultAction", automation.Action -) -SenseAirABCEnableAction = senseair_ns.class_( - "SenseAirABCEnableAction", automation.Action -) -SenseAirABCDisableAction = senseair_ns.class_( - "SenseAirABCDisableAction", automation.Action -) -SenseAirABCGetPeriodAction = senseair_ns.class_( - "SenseAirABCGetPeriodAction", automation.Action -) CONFIG_SCHEMA = ( cv.Schema( @@ -82,41 +65,13 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "senseair.background_calibration", - SenseAirBackgroundCalibrationAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "senseair.background_calibration_result", - SenseAirBackgroundCalibrationResultAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "senseair.abc_enable", - SenseAirABCEnableAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "senseair.abc_disable", - SenseAirABCDisableAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "senseair.abc_get_period", - SenseAirABCGetPeriodAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) -async def senseair_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("senseair.background_calibration", "background_calibration()"), + ("senseair.background_calibration_result", "background_calibration_result()"), + ("senseair.abc_enable", "abc_enable()"), + ("senseair.abc_disable", "abc_disable()"), + ("senseair.abc_get_period", "abc_get_period()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c0..f3293d4ed56 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -111,9 +111,10 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, @@ -122,6 +123,7 @@ from esphome.core.entity_helpers import ( ) from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -256,7 +258,6 @@ SensorPtr = Sensor.operator("ptr") ValueRangeTrigger = sensor_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) -SensorPublishAction = sensor_ns.class_("SensorPublishAction", automation.Action) # Filters Filter = sensor_ns.class_("Filter") @@ -344,7 +345,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( @@ -1012,6 +1015,13 @@ async def new_sensor(config, *args): return var +def sub_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_sensor.""" + return SubEntities(new_sensor, config, parent) + + SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Sensor), diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 35a4a29e0d2..fdd20461e77 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,31 +6,6 @@ namespace esphome::sensor { -class SensorStateTrigger final : public Trigger { - public: - explicit SensorStateTrigger(Sensor *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - -class SensorRawStateTrigger final : public Trigger { - public: - explicit SensorRawStateTrigger(Sensor *parent) { - parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); - } -}; - -template class SensorPublishAction final : public Action { - public: - SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(float, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - Sensor *sensor_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 59e011932b1..bee5d7c6d33 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) { return StateClassStrings::get_log_str(static_cast(state_class), 0); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -Sensor::Sensor() : state(NAN), raw_state(NAN) {} -#pragma GCC diagnostic pop +Sensor::Sensor() : state(NAN) {} int8_t Sensor::get_accuracy_decimals() { if (this->sensor_flags_.has_accuracy_override) @@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() { } void Sensor::publish_state(float state) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->raw_state = state; -#pragma GCC diagnostic pop #ifdef USE_SENSOR_FILTER + this->raw_state_ = state; this->raw_callback_.call(state); #endif diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f4ea4af9851..20288fa88e0 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -96,18 +96,20 @@ class Sensor : public EntityBase { /// Getter-syntax for .state. float get_state() const { return this->state; } - /// Getter-syntax for .raw_state + /// Get the last state received by publish_state(), before any filters were applied. float get_raw_state() const { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop +#ifdef USE_SENSOR_FILTER + return this->raw_state_; +#else + return this->state; // No filters compiled in, raw == filtered +#endif } /** Publish a new state to the front-end. * - * First, the new state will be assigned to the raw_value. Then it's passed through all filters - * until it finally lands in the .value member variable and a callback is issued. + * The value is passed through the filter chain (when filters are compiled in) before landing in + * the `state` member and triggering the state callback. The pre-filter value is available via + * get_raw_state(). * * @param state The state as a floating point number. */ @@ -137,17 +139,11 @@ class Sensor : public EntityBase { */ float state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0") - float raw_state; -#pragma GCC diagnostic pop - void internal_send_state_to_frontend(float state); protected: #ifdef USE_SENSOR_FILTER + float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 4186fcf8b13..b6e780fabdc 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -30,6 +30,7 @@ MULTI_CONF = True serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) +SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index c1c15106438..129745c1c91 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -29,26 +29,57 @@ void SerialProxy::setup() { #ifdef USE_API // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; +#endif +#ifdef USE_SERIAL_PROXY_TAP + // A tap sets itself up before this runs (its setup priority is higher), so it may + // already be waiting on the port -- a boot-time handshake with the device, say. Leaving + // the loop enabled is what lets that finish; without it the tap would stall until a + // client happened to subscribe. + if (this->tap_ != nullptr && this->tap_->tap_needs_port()) { + return; + } #endif // No subscriber at startup; disable loop until a client subscribes this->disable_loop(); } -void SerialProxy::loop() { -#ifdef USE_API - // Safety check — loop should only run when subscribed, but guard against races - if (this->api_connection_ == nullptr) [[unlikely]] { - this->disable_loop(); +#ifdef USE_SERIAL_PROXY_TAP +void SerialProxy::reset_mode_() { + // The mode belongs to a session, not to the port. Carrying a departed client's choice + // over to the next one would inject protocol bytes into a stream that never asked for + // them -- a firmware upload, or any client built before this request existed and so + // unable to turn it off. Guessing RAW is the safe direction: a client that wanted + // protocol handling and did not ask for it merely sends its own acknowledgements. + if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) { return; } + ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); + this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; +} +#endif +void SerialProxy::loop() { +#ifdef USE_API // Detect subscriber disconnect - if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || - !api_is_connected()) { + if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || + !this->api_connection_->is_connection_setup() || !api_is_connected())) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; + this->reset_mode_(); + } + + // With no subscriber there is normally nothing to do, but a tap may still need the port + // read -- it does its protocol work precisely while nobody else is listening. + if (this->api_connection_ == nullptr) [[unlikely]] { +#ifdef USE_SERIAL_PROXY_TAP + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + return; + } +#else this->disable_loop(); return; +#endif } // Read available data from UART and forward to subscribed client @@ -69,11 +100,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { if (!this->read_array(buffer, to_read)) return; +#ifdef USE_SERIAL_PROXY_TAP + // Before forwarding, so a tap that answers the device (an acknowledgement, say) is not + // waiting on the network round trip to a subscriber that may not even exist. + if (this->tap_observing_()) { + this->tap_->on_device_rx(buffer, to_read); + } +#endif + + if (this->api_connection_ == nullptr) { + return; + } this->outgoing_msg_.set_data(buffer, to_read); this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); } #endif +#ifdef USE_SERIAL_PROXY_TAP + +bool SerialProxy::tap_observing_() const { + if (this->tap_ == nullptr) { + return false; + } + // With no subscriber, a tap doing its own protocol work (the boot-time handshake with + // the device, say) is served regardless of mode -- nobody has chosen one yet. Once a + // subscriber holds the port, the mode alone decides, so RAW stays inert. + if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) { + return true; + } + // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before + // flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes + // into it, and "the tap turned out not to recognise the stream" is not good enough. + return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL; +} + +void SerialProxy::tap_pump() { +#ifdef USE_API + // Nothing would consume the bytes; leave them in the FIFO + if (!this->tap_observing_() && this->api_connection_ == nullptr) { + return; + } + const size_t available = this->available(); + if (available > 0) { + this->read_and_send_(available); + } +#endif +} +#endif + void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, "Serial Proxy [%" PRIu32 "]:\n" @@ -92,8 +166,9 @@ void SerialProxy::dump_config() { SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -159,24 +234,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection, + api::enums::SerialProxyMode mode) { +#ifdef USE_API + // Only the live subscriber may change the mode, so the mode cannot outlive a session + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; + } +#endif + // Values come from a remote client + if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) { + ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast(mode)); + return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; + } + // PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows +#ifdef USE_SERIAL_PROXY_TAP + const bool has_tap = this->tap_ != nullptr; +#else + const bool has_tap = false; +#endif + if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) { + ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; + } + ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, + mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); +#ifdef USE_SERIAL_PROXY_TAP + const bool leaving_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; + this->mode_ = mode; + + // Only for an explicit client request, not for reset_mode_() at the end of a session: + // an ordinary disconnect says nothing about the device, whereas a client deliberately + // asking for raw bytes usually precedes changing what the device is. + if (leaving_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_disabled(); + } +#endif + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; +} + void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API - // Bytes from a client other than the live subscriber would interleave with the - // subscriber's traffic on the wire - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + // Bytes from anyone but the live subscriber would interleave with the subscriber's + // traffic -- or with an active tap's -- on the wire + if (!this->is_subscriber_(api_connection)) { + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_); + } else { + // A legacy client streaming writes without subscribing would flood WARN, one per + // request; writes are the only high-rate, unacknowledged operation, so keep this + // visible without drowning the log + ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); + } return; } #endif if (data == nullptr || len == 0) return; this->write_array(data, len); + +#ifdef USE_SERIAL_PROXY_TAP + // After the write, so the tap observes the same ordering the device does + if (this->tap_observing_()) { + this->tap_->on_client_tx(data, len); + } +#endif } SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -210,8 +341,8 @@ uint32_t SerialProxy::get_modem_pins() const { SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { #ifdef USE_API // Flushing stalls the port, so it gets the same ownership check as writes - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -230,11 +361,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { } #ifdef USE_API -bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { - return this->api_connection_ != nullptr && this->api_connection_ != api_connection && - this->api_connection_->is_connection_setup(); -} - SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { @@ -252,6 +378,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + // End the dead client's session before starting the new one, so its mode + // cannot leak into a session that never asked for it + this->api_connection_ = nullptr; + this->reset_mode_(); } this->api_connection_ = api_connection; this->enable_loop(); @@ -264,7 +394,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } this->api_connection_ = nullptr; + this->reset_mode_(); +#ifdef USE_SERIAL_PROXY_TAP + // Keep the loop alive for a tap that still needs the port (mirrors loop()) + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + } +#else this->disable_loop(); +#endif ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; default: diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index a0e47ee6864..e3f4264cfa5 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -26,6 +26,7 @@ class APIConnection; namespace enums { enum SerialProxyPortType : uint32_t; enum SerialProxyRequestType : uint32_t; +enum SerialProxyMode : uint32_t; } // namespace enums } // namespace esphome::api @@ -52,6 +53,36 @@ enum class SerialProxyResult : uint8_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; +#ifdef USE_SERIAL_PROXY_TAP +/// Observes a port's traffic without owning it, and may inject bytes of its own. +/// +/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without +/// the pipe knowing anything about the protocol: the tap is compiled in only when some +/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it. +/// +/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes +/// flowing in either direction, so a misbehaving tap cannot corrupt the stream. +class SerialProxyTap { + public: + /// Bytes read from the device, before they are forwarded to any subscriber. + virtual void on_device_rx(const uint8_t *data, size_t len) = 0; + + /// Bytes a subscriber sent towards the device, after they have been written. + virtual void on_client_tx(const uint8_t *data, size_t len) = 0; + + /// True when the port must keep reading even with no subscriber attached, so a tap can + /// do its own protocol work while nobody is listening. Honoured only while no + /// subscriber holds the port; with one attached, the port mode alone decides. + virtual bool tap_needs_port() const = 0; + + /// A client explicitly turned protocol handling off for this port. Distinct from the + /// automatic reset when a session ends: this one means a client intends to do something + /// else with the device -- reflash it, most likely -- so anything the tap believes about + /// it should be treated as suspect. + virtual void on_protocol_disabled() = 0; +}; +#endif + class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; @@ -77,6 +108,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + /// Handle a mode change requested by an API client + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second @@ -121,13 +155,67 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Set the DTR GPIO pin (from YAML configuration) void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } +#ifdef USE_SERIAL_PROXY_TAP + /// Attach a traffic observer. At most one, set once at setup time. + void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } + + /// Write bytes originating from the tap rather than from a client. Bypasses the + /// subscriber ownership check, but only while the tap is being served bytes -- so a + /// port in RAW mode with a subscriber attached stays inert. Returns false when the + /// bytes were dropped for that reason. + bool write_from_tap(const uint8_t *data, size_t len) { + if (!this->tap_observing_()) { + return false; + } + this->write_array(data, len); + return true; + } + + /// Whether the tap is currently being served bytes. Can flip false with no callback + /// (a subscriber attaching in RAW mode, say), so a tap should check before starting + /// protocol work and when a reply seems overdue. + bool tap_is_observed() const { return this->tap_observing_(); } + + /// Resume reading after a tap's needs change. loop() disables itself when there is + /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work + /// must ask for it back. Must be called from the main loop. + void tap_request_port() { this->enable_loop(); } + + /// Whether the underlying device is present. On a USB UART this tracks enumeration, so + /// a tap can notice the device being unplugged and plugged back in. + bool is_device_connected() const { return this->parent_->is_connected(); } + + /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the + /// main loop is running -- during setup, for instance, while a component is still + /// blocking on can_proceed(). Must not be called from on_device_rx() or + /// on_client_tx(): each nested cycle costs a 256-byte stack frame. + void tap_pump(); +#endif + protected: #ifdef USE_API - /// Read from UART and send to API client (slow path with 256-byte stack buffer) + /// Read from UART, hand the bytes to any tap, and forward them to a subscriber + /// (slow path with a 256-byte stack buffer) void read_and_send_(size_t available); - /// True when a live subscriber other than the given connection holds the port - bool port_claimed_by_other_(api::APIConnection *api_connection) const; + /// True when the given connection is the live subscriber. Every port operation + /// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed + /// client can never share the wire with the subscriber or an active tap. + bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// Return the port to RAW when a subscriber goes away, so the mode never outlives it + void reset_mode_(); +#else + /// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is + /// nothing to reset + void reset_mode_() {} +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// True when the tap should be shown the traffic passing through this port + bool tap_observing_() const; #endif /// Instance index for identifying this proxy in API messages @@ -147,6 +235,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; +#ifdef USE_SERIAL_PROXY_TAP + /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW + api::enums::SerialProxyMode mode_{}; +#endif + /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr}; GPIOPin *dtr_pin_{nullptr}; @@ -154,6 +247,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Current modem pin states bool rts_state_{false}; bool dtr_state_{false}; + +#ifdef USE_SERIAL_PROXY_TAP + SerialProxyTap *tap_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 666c7dbcddf..74acaa34962 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -13,14 +13,10 @@ from esphome.const import ( CONF_RESTORE, CONF_TRANSITION_LENGTH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) -ServoWriteAction = servo_ns.class_("ServoWriteAction", automation.Action) -ServoDetachAction = servo_ns.class_("ServoDetachAction", automation.Action) CONF_AUTO_DETACH_TIME = "auto_detach_time" MULTI_CONF = True @@ -56,45 +52,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transition_length(config[CONF_TRANSITION_LENGTH])) -@automation.register_action( +automation.register_apply_action( "servo.write", - ServoWriteAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(Servo), cv.Required(CONF_LEVEL): cv.templatable(cv.possibly_negative_percentage), } ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "write", cg.float_), ) -async def servo_write_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) - cg.add(var.set_value(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "servo.detach", - ServoDetachAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Servo), } ), - synchronous=True, + automation.ApplyCall("detach()"), ) -async def servo_detach_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 156dab6dc1e..ce29c783e20 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -51,25 +50,4 @@ class Servo final : public Component { }; }; -template class ServoWriteAction final : public Action { - public: - ServoWriteAction(Servo *servo) : servo_(servo) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { this->servo_->write(this->value_.value(x...)); } - - protected: - Servo *servo_; -}; - -template class ServoDetachAction final : public Action { - public: - ServoDetachAction(Servo *servo) : servo_(servo) {} - - void play(const Ts &...x) override { this->servo_->detach(); } - - protected: - Servo *servo_; -}; - } // namespace esphome::servo diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index beb549ce706..b56df3fb2e1 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_HUMIDITY_SOURCE import esphome.config_validation as cv from esphome.const import ( CONF_BASELINE, @@ -31,7 +32,6 @@ SGP30Component = sgp30_ns.class_( CONF_ECO2_BASELINE = "eco2_baseline" CONF_TVOC_BASELINE = "tvoc_baseline" CONF_UPTIME = "uptime" -CONF_HUMIDITY_SOURCE = "humidity_source" CONFIG_SCHEMA = ( @@ -87,21 +87,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_eco2_sensor(sens)) - - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc_sensor(sens)) - - if eco2_baseline_config := config.get(CONF_ECO2_BASELINE): - sens = await sensor.new_sensor(eco2_baseline_config) - cg.add(var.set_eco2_baseline_sensor(sens)) - - if tvoc_baseline_config := config.get(CONF_TVOC_BASELINE): - sens = await sensor.new_sensor(tvoc_baseline_config) - cg.add(var.set_tvoc_baseline_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2_BASELINE, var.set_eco2_baseline_sensor) + await sensors(CONF_TVOC_BASELINE, var.set_tvoc_baseline_sensor) if (store_baseline := config.get(CONF_STORE_BASELINE)) is not None: cg.add(var.set_store_baseline(store_baseline)) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1a88d190e4f..bf8fbe1a975 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -1,6 +1,10 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor -from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX +from esphome.components.const import ( + CONF_HUMIDITY_SOURCE, + CONF_NOX_INDEX, + CONF_VOC_INDEX, +) import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -33,8 +37,6 @@ SGP4xComponent = sgp4x_ns.class_( sensirion_common.SensirionI2CDevice, ) -CONF_HUMIDITY_SOURCE = "humidity_source" - def validate_sensors(config: ConfigType) -> ConfigType: if CONF_VOC_INDEX not in config and CONF_NOX_INDEX not in config: diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index ae7ee6fa59f..c472e01f83a 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -11,15 +11,6 @@ MULTI_CONF = True sim800l_ns = cg.esphome_ns.namespace("sim800l") Sim800LComponent = sim800l_ns.class_("Sim800LComponent", cg.Component) -# Actions -Sim800LSendSmsAction = sim800l_ns.class_("Sim800LSendSmsAction", automation.Action) -Sim800LSendUssdAction = sim800l_ns.class_("Sim800LSendUssdAction", automation.Action) -Sim800LDialAction = sim800l_ns.class_("Sim800LDialAction", automation.Action) -Sim800LConnectAction = sim800l_ns.class_("Sim800LConnectAction", automation.Action) -Sim800LDisconnectAction = sim800l_ns.class_( - "Sim800LDisconnectAction", automation.Action -) - CONF_SIM800L_ID = "sim800l_id" CONF_ON_SMS_RECEIVED = "on_sms_received" CONF_ON_USSD_RECEIVED = "on_ussd_received" @@ -90,21 +81,14 @@ SIM800L_SEND_SMS_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "sim800l.send_sms", - Sim800LSendSmsAction, SIM800L_SEND_SMS_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_sms({}, {})", + ((CONF_RECIPIENT, cg.std_string), (CONF_MESSAGE, cg.std_string)), + ), ) -async def sim800l_send_sms_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RECIPIENT], args, cg.std_string) - cg.add(var.set_recipient(template_)) - template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) - cg.add(var.set_message(template_)) - return var - SIM800L_DIAL_SCHEMA = cv.Schema( { @@ -113,28 +97,11 @@ SIM800L_DIAL_SCHEMA = cv.Schema( } ) - -@automation.register_action( - "sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA, synchronous=True +automation.register_apply_action( + "sim800l.dial", + SIM800L_DIAL_SCHEMA, + automation.ApplyField(CONF_RECIPIENT, "dial", cg.std_string), ) -async def sim800l_dial_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RECIPIENT], args, cg.std_string) - cg.add(var.set_recipient(template_)) - return var - - -@automation.register_action( - "sim800l.connect", - Sim800LConnectAction, - cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), - synchronous=True, -) -async def sim800l_connect_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - SIM800L_SEND_USSD_SCHEMA = cv.Schema( { @@ -143,27 +110,17 @@ SIM800L_SEND_USSD_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "sim800l.send_ussd", - Sim800LSendUssdAction, SIM800L_SEND_USSD_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_USSD, "send_ussd", cg.std_string), ) -async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_USSD], args, cg.std_string) - cg.add(var.set_ussd(template_)) - return var +SIM800L_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}) -@automation.register_action( - "sim800l.disconnect", - Sim800LDisconnectAction, - cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), - synchronous=True, +automation.register_apply_action( + "sim800l.connect", SIM800L_ID_SCHEMA, automation.ApplyCall("connect()") +) +automation.register_apply_action( + "sim800l.disconnect", SIM800L_ID_SCHEMA, automation.ApplyCall("disconnect()") ) -async def sim800l_disconnect_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 276131cfed8..bbe8e14cf7e 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -120,67 +120,4 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction final : public Action { - public: - Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, recipient) - TEMPLATABLE_VALUE(std::string, message) - - void play(const Ts &...x) { - auto recipient = this->recipient_.value(x...); - auto message = this->message_.value(x...); - this->parent_->send_sms(recipient, message); - } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LSendUssdAction final : public Action { - public: - Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, ussd) - - void play(const Ts &...x) { - auto ussd_code = this->ussd_.value(x...); - this->parent_->send_ussd(ussd_code); - } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LDialAction final : public Action { - public: - Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, recipient) - - void play(const Ts &...x) { - auto recipient = this->recipient_.value(x...); - this->parent_->dial(recipient); - } - - protected: - Sim800LComponent *parent_; -}; -template class Sim800LConnectAction final : public Action { - public: - Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->connect(); } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LDisconnectAction final : public Action { - public: - Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->disconnect(); } - - protected: - Sim800LComponent *parent_; -}; - } // namespace esphome::sim800l diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index e0f2b7b280e..4245283ab48 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -99,24 +99,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_sensor(sens)) - if CONF_FORMALDEHYDE in config: - sens = await sensor.new_sensor(config[CONF_FORMALDEHYDE]) - cg.add(var.set_formaldehyde_sensor(sens)) - if CONF_TVOC in config: - sens = await sensor.new_sensor(config[CONF_TVOC]) - cg.add(var.set_tvoc_sensor(sens)) - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_FORMALDEHYDE, var.set_formaldehyde_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index 7ba7da801cd..dae98bc1897 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -83,22 +83,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_COUNTS in config: - sens = await sensor.new_sensor(config[CONF_COUNTS]) - cg.add(var.set_counts_sensor(sens)) - - if CONF_PERMITTIVITY in config: - sens = await sensor.new_sensor(config[CONF_PERMITTIVITY]) - cg.add(var.set_permittivity_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture_sensor(sens)) - - if CONF_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_VOLTAGE]) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_COUNTS, var.set_counts_sensor) + await sensors(CONF_PERMITTIVITY, var.set_permittivity_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_MOISTURE, var.set_moisture_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/snapshot/__init__.py b/esphome/components/snapshot/__init__.py index bf561a0e0da..1404263ce63 100644 --- a/esphome/components/snapshot/__init__.py +++ b/esphome/components/snapshot/__init__.py @@ -12,43 +12,52 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] DOMAIN = "snapshot" CONF_FILENAME = "filename" +CONF_FRAMES = "frames" +CONF_FRAME_RATE = "frame_rate" snapshot_ns = cg.esphome_ns.namespace("snapshot") Snapshot = snapshot_ns.class_("Snapshot") -SnapshotAction = snapshot_ns.class_("SnapshotAction", automation.Action) -@automation.register_action( +def _default_animation(config: ConfigType) -> ConfigType: + """Without frames the action takes a single picture, which the C++ side reads as zero frames.""" + return {CONF_FRAMES: 0, CONF_FRAME_RATE: 0.0, **config} + + +automation.register_apply_action( "snapshot.take", - SnapshotAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Snapshot), - cv.Optional(CONF_FILENAME): cv.templatable(cv.string), - } + cv.All( + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Snapshot), + cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), + # Asking for frames makes a GIF instead of a single BMP picture. + cv.Inclusive(CONF_FRAMES, "animation"): cv.positive_not_null_int, + cv.Inclusive(CONF_FRAME_RATE, "animation"): cv.All( + cv.framerate, cv.Range(min=0.1, max=50) + ), + } + ), + _default_animation, + ), + automation.ApplyCall( + "take_snapshot_or_log({}, {}, {})", + ( + (CONF_FILENAME, cg.std_string), + (CONF_FRAMES, cg.uint32), + (CONF_FRAME_RATE, cg.float_), + ), ), - synchronous=True, ) -async def snapshot_take_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 (filename := config.get(CONF_FILENAME)) is not None: - cg.add(var.set_filename(await cg.templatable(filename, args, cg.std_string))) - return var @dataclass diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp new file mode 100644 index 00000000000..5650c1c404b --- /dev/null +++ b/esphome/components/snapshot/gif.cpp @@ -0,0 +1,283 @@ +#ifdef USE_HOST +#include "gif.h" + +#include +#include +#include + +namespace esphome::snapshot { + +namespace { + +constexpr size_t MAX_PALETTE_SIZE = 256; +// GIF needs at least two bits for each colour, even when the picture has fewer than four colours. +constexpr unsigned MIN_PALETTE_BITS = 2; +constexpr unsigned MAX_CODE_BITS = 12; +constexpr uint32_t MAX_CODES = 1u << MAX_CODE_BITS; +// Twice the number of codes there can be, so a lookup rarely has to look at more than one entry. +constexpr size_t DICTIONARY_SIZE = 2 * MAX_CODES; +constexpr uint32_t EMPTY_ENTRY = 0xFFFFFFFF; +constexpr size_t MAX_SUB_BLOCK_SIZE = 255; +// Colours are grouped by their top 5, 6 and 5 bits, which is all an RGB565 display can show. +constexpr size_t COLOR_KEY_COUNT = 1u << 16; + +constexpr uint16_t color_key(uint8_t red, uint8_t green, uint8_t blue) { + return static_cast(((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3)); +} + +/// One of the three parts of a colour key: 0 for red, 1 for green, 2 for blue. The value is spread +/// over 0 to 255 whichever part it is, so the parts can be compared with each other. +constexpr unsigned key_channel(uint16_t key, unsigned channel) { + switch (channel) { + case 0: + return (key >> 11) << 3; + case 1: + return ((key >> 5) & 0x3F) << 2; + default: + return (key & 0x1F) << 3; + } +} + +/// Store a value in two bytes, least significant first. +void put_le16(std::vector &out, unsigned value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast(value >> 8)); +} + +/// Packs codes of varying length into bytes, least significant bit first. +struct BitWriter { + std::vector &out; + uint32_t buffer{0}; + unsigned count{0}; + + void put(uint32_t code, unsigned size) { + this->buffer |= code << this->count; + this->count += size; + while (this->count >= 8) { + this->out.push_back(static_cast(this->buffer & 0xFF)); + this->buffer >>= 8; + this->count -= 8; + } + } + + void flush() { + if (this->count > 0) + this->out.push_back(static_cast(this->buffer & 0xFF)); + this->buffer = 0; + this->count = 0; + } +}; + +/// Compress palette indices the way GIF asks for, appending the bytes to `out`. +void lzw_compress(const uint8_t *data, size_t length, unsigned min_code_size, std::vector &out) { + struct Entry { + uint32_t key; // the code before, shifted up a byte, with the next index in the low byte + uint16_t code; + }; + const uint32_t clear_code = 1u << min_code_size; + const uint32_t end_code = clear_code + 1; + std::vector dictionary(DICTIONARY_SIZE); + BitWriter bits{out}; + uint32_t next_code = 0; + unsigned code_size = 0; + + auto reset = [&]() { + std::fill(dictionary.begin(), dictionary.end(), Entry{EMPTY_ENTRY, 0}); + next_code = end_code + 1; + code_size = min_code_size + 1; + }; + + reset(); + bits.put(clear_code, code_size); + uint32_t prefix = data[0]; + for (size_t i = 1; i != length; i++) { + const uint32_t key = prefix << 8 | data[i]; + size_t slot = (key * 2654435761u >> 16) & (DICTIONARY_SIZE - 1); + while (dictionary[slot].key != EMPTY_ENTRY && dictionary[slot].key != key) + slot = (slot + 1) & (DICTIONARY_SIZE - 1); + if (dictionary[slot].key == key) { + prefix = dictionary[slot].code; + continue; + } + bits.put(prefix, code_size); + if (next_code < MAX_CODES) { + dictionary[slot] = Entry{key, static_cast(next_code++)}; + // The reader is one entry behind, so it moves to longer codes a step after this does. + if (next_code > (1u << code_size)) + code_size++; + } else { + bits.put(clear_code, code_size); + reset(); + } + prefix = data[i]; + } + bits.put(prefix, code_size); + // The reader adds an entry for that last code, which may take it to longer codes. + if (next_code == (1u << code_size) && code_size < MAX_CODE_BITS) + code_size++; + bits.put(end_code, code_size); + bits.flush(); +} + +} // namespace + +GifWriter::GifWriter(FILE *file, int width, int height) + : file_(file), + width_(width), + height_(height), + bins_(COLOR_KEY_COUNT), + palette_index_(COLOR_KEY_COUNT), + indices_(static_cast(width) * height) {} + +bool GifWriter::write_header() { + this->output_.clear(); + static constexpr char SIGNATURE[] = "GIF89a"; + this->output_.insert(this->output_.end(), SIGNATURE, SIGNATURE + 6); + put_le16(this->output_, this->width_); + put_le16(this->output_, this->height_); + this->output_.push_back(0x70); // no shared colour table; 8 bits per colour + this->output_.push_back(0); // background colour, unused + this->output_.push_back(0); // pixels are square + // Ask players to repeat the animation forever. + static constexpr char LOOP_EXTENSION[] = "\x21\xFF\x0BNETSCAPE2.0\x03\x01"; + this->output_.insert(this->output_.end(), LOOP_EXTENSION, LOOP_EXTENSION + sizeof(LOOP_EXTENSION) - 1); + put_le16(this->output_, 0); + this->output_.push_back(0); + return fwrite(this->output_.data(), 1, this->output_.size(), this->file_) == this->output_.size(); +} + +size_t GifWriter::build_palette_(uint8_t *palette) { + struct Box { + size_t begin; // range of used_colors_, which the box owns + size_t end; + uint64_t pixels; + }; + + this->used_colors_.clear(); + uint64_t total = 0; + for (size_t key = 0; key != COLOR_KEY_COUNT; key++) { + if (this->bins_[key].count != 0) { + this->used_colors_.push_back(static_cast(key)); + total += this->bins_[key].count; + } + } + + // Median cut: keep cutting the box with the most pixels in two, across the colour part that + // varies most, until there is a box for each palette entry or every box is a single colour. + std::vector boxes; + boxes.push_back({0, this->used_colors_.size(), total}); + while (boxes.size() < MAX_PALETTE_SIZE) { + size_t chosen = boxes.size(); + for (size_t i = 0; i != boxes.size(); i++) { + if (boxes[i].end - boxes[i].begin >= 2 && (chosen == boxes.size() || boxes[i].pixels > boxes[chosen].pixels)) + chosen = i; + } + if (chosen == boxes.size()) + break; + const Box box = boxes[chosen]; + + unsigned low[3] = {255, 255, 255}; + unsigned high[3] = {0, 0, 0}; + for (size_t i = box.begin; i != box.end; i++) { + for (unsigned channel = 0; channel != 3; channel++) { + const unsigned value = key_channel(this->used_colors_[i], channel); + low[channel] = std::min(low[channel], value); + high[channel] = std::max(high[channel], value); + } + } + unsigned widest = 0; + for (unsigned channel = 1; channel != 3; channel++) { + if (high[channel] - low[channel] > high[widest] - low[widest]) + widest = channel; + } + std::sort(this->used_colors_.begin() + box.begin, this->used_colors_.begin() + box.end, + [widest](uint16_t a, uint16_t b) { + const unsigned value_a = key_channel(a, widest); + const unsigned value_b = key_channel(b, widest); + return value_a != value_b ? value_a < value_b : a < b; + }); + + // Cut where the pixels are half on one side and half on the other, keeping a colour on each. + size_t cut = box.begin; + uint64_t below = 0; + while (cut < box.end - 1 && below * 2 < box.pixels) + below += this->bins_[this->used_colors_[cut++]].count; + boxes[chosen] = {box.begin, cut, below}; + boxes.push_back({cut, box.end, box.pixels - below}); + } + + for (size_t i = 0; i != boxes.size(); i++) { + uint64_t sum[3] = {0, 0, 0}; + for (size_t j = boxes[i].begin; j != boxes[i].end; j++) { + const ColorBin &bin = this->bins_[this->used_colors_[j]]; + for (unsigned channel = 0; channel != 3; channel++) + sum[channel] += bin.sum[channel]; + this->palette_index_[this->used_colors_[j]] = static_cast(i); + } + // The average colour of the box, rounded to the nearest. + for (unsigned channel = 0; channel != 3; channel++) + palette[i * 3 + channel] = static_cast((sum[channel] + boxes[i].pixels / 2) / boxes[i].pixels); + } + return boxes.size(); +} + +bool GifWriter::write_frame(const uint8_t *bgr, size_t row_stride, unsigned delay_centiseconds) { + std::fill(this->bins_.begin(), this->bins_.end(), ColorBin{}); + for (int y = 0; y != this->height_; y++) { + const uint8_t *in = bgr + y * row_stride; + for (int x = 0; x != this->width_; x++, in += 3) { + ColorBin &bin = this->bins_[color_key(in[2], in[1], in[0])]; + bin.count++; + bin.sum[0] += in[2]; + bin.sum[1] += in[1]; + bin.sum[2] += in[0]; + } + } + + uint8_t palette[MAX_PALETTE_SIZE * 3] = {}; + const size_t colors = this->build_palette_(palette); + unsigned palette_bits = MIN_PALETTE_BITS; + while ((size_t{1} << palette_bits) < colors) + palette_bits++; + + uint8_t *out_index = this->indices_.data(); + for (int y = 0; y != this->height_; y++) { + const uint8_t *in = bgr + y * row_stride; + for (int x = 0; x != this->width_; x++, in += 3) + *out_index++ = this->palette_index_[color_key(in[2], in[1], in[0])]; + } + + this->output_.clear(); + // Show the frame for the time asked, then leave it in place under the next one. + static constexpr uint8_t GRAPHIC_CONTROL[] = {0x21, 0xF9, 0x04, 0x04}; + this->output_.insert(this->output_.end(), std::begin(GRAPHIC_CONTROL), std::end(GRAPHIC_CONTROL)); + put_le16(this->output_, delay_centiseconds); + this->output_.push_back(0); // no transparent colour + this->output_.push_back(0); + this->output_.push_back(0x2C); // the frame fills the whole picture + put_le16(this->output_, 0); + put_le16(this->output_, 0); + put_le16(this->output_, this->width_); + put_le16(this->output_, this->height_); + this->output_.push_back(static_cast(0x80 | (palette_bits - 1))); // has its own colour table + this->output_.insert(this->output_.end(), palette, palette + (size_t{3} << palette_bits)); + this->output_.push_back(static_cast(palette_bits)); + + std::vector compressed; + lzw_compress(this->indices_.data(), this->indices_.size(), palette_bits, compressed); + for (size_t pos = 0; pos < compressed.size(); pos += MAX_SUB_BLOCK_SIZE) { + const size_t length = std::min(MAX_SUB_BLOCK_SIZE, compressed.size() - pos); + this->output_.push_back(static_cast(length)); + this->output_.insert(this->output_.end(), compressed.begin() + pos, compressed.begin() + pos + length); + } + this->output_.push_back(0); + return fwrite(this->output_.data(), 1, this->output_.size(), this->file_) == this->output_.size(); +} + +bool GifWriter::write_trailer() { + const uint8_t trailer = 0x3B; + return fwrite(&trailer, 1, 1, this->file_) == 1; +} + +} // namespace esphome::snapshot +#endif diff --git a/esphome/components/snapshot/gif.h b/esphome/components/snapshot/gif.h new file mode 100644 index 00000000000..382f6499762 --- /dev/null +++ b/esphome/components/snapshot/gif.h @@ -0,0 +1,51 @@ +#pragma once + +#ifdef USE_HOST +#include +#include +#include +#include + +namespace esphome::snapshot { + +/// Writes an animated GIF a frame at a time, so a long recording is never held in memory. +/// +/// A GIF frame can use at most 256 colours. Each frame gets its own colour table, chosen from what +/// is in that frame, so a frame with 256 colours or fewer is stored exactly. +class GifWriter { + public: + GifWriter(FILE *file, int width, int height); + + /// Write the start of the file. This must come first. The animation repeats forever. + bool write_header(); + /// Add a frame from three bytes per pixel in blue, green, red order, topmost row first, with + /// `row_stride` bytes from the start of one row to the start of the next. + /// The frame is shown for `delay_centiseconds` hundredths of a second. + bool write_frame(const uint8_t *bgr, size_t row_stride, unsigned delay_centiseconds); + /// Write the end of the file. This must come last. + bool write_trailer(); + + protected: + /// How many pixels of one colour there are in the frame, and the sum of their red, green and blue. + struct ColorBin { + uint64_t sum[3]; + uint32_t count; + }; + + /// Choose up to 256 colours for the frame in `bins_`, storing them as red, green, blue triples in + /// `palette` and which one each colour is nearest in `palette_index_`. Returns how many were chosen. + size_t build_palette_(uint8_t *palette); + + FILE *file_; + int width_; + int height_; + std::vector bins_; + std::vector used_colors_; + std::vector palette_index_; + std::vector indices_; + std::vector output_; +}; + +} // namespace esphome::snapshot + +#endif diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 995f87710e9..096ec48eb3b 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -1,5 +1,8 @@ #ifdef USE_HOST #include "snapshot.h" +#include "gif.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -7,11 +10,13 @@ #include #include #include +#include #include #include #include #include #include +#include namespace esphome::snapshot { @@ -28,16 +33,19 @@ constexpr size_t BMP_HEADER_SIZE = 54; constexpr size_t BMP_INFO_HEADER_SIZE = 40; constexpr int BMP_BITS_PER_PIXEL = 24; -/// True if the name already ends in ".bmp". The comparison ignores case, so "shot.BMP" is left -/// alone rather than turned into "shot.BMP.bmp". -bool has_bmp_suffix(const std::string &name) { - return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, ".bmp") == 0; +// The largest width or height a GIF file can hold. +constexpr int GIF_MAX_SIZE = 0xFFFF; + +/// True if the name already ends in the extension, which is four characters including the dot. The +/// comparison ignores case, so "shot.BMP" is left alone rather than turned into "shot.BMP.bmp". +bool has_extension(const std::string &name, const char *extension) { + return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, extension) == 0; } /// Reduce a user supplied name to a single safe path component. Everything outside the allowed set /// is replaced, so "..", "/" and absolute paths cannot escape the snapshot directory. /// Returns an empty string if nothing usable is left. -std::string sanitise_filename(const char *const name, bool *name_changed) { +std::string sanitise_filename(const char *const name, const char *extension, bool *name_changed) { std::string result; bool all_dots = true; bool changed = false; @@ -59,8 +67,8 @@ std::string sanitise_filename(const char *const name, bool *name_changed) { *name_changed = true; return ""; } - if (!has_bmp_suffix(result)) - result += ".bmp"; + if (!has_extension(result, extension)) + result += extension; *name_changed = changed; return result; } @@ -89,15 +97,15 @@ void put_le(uint8_t *&dest, uint32_t value, size_t bytes) { *dest++ = static_cast(value >> (8 * i)); } -/// The number of bytes one row of `width` pixels takes up in the file. Rows are padded out to a -/// multiple of four bytes. -size_t bmp_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } +/// The number of bytes one row of `width` pixels takes up in a picture buffer. Rows are padded out +/// to a multiple of four bytes, which is also what a BMP file wants. +size_t padded_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } /// Write pixels out as a 24 bit BMP. The rows given start with the topmost and are `row_stride` /// bytes apart, which must leave room for a whole padded row; a BMP holds its rows the other way /// up, so they go out last first. bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t row_stride) { - const size_t row_size = bmp_row_size(width); + const size_t row_size = padded_row_size(width); const size_t pixel_bytes = row_size * height; uint8_t header[BMP_HEADER_SIZE]; @@ -128,21 +136,19 @@ bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t return true; } -/// Reserve a name in the snapshot directory and write the picture to it. +/// Reserve a name in the snapshot directory and open a new file under it. /// With `exact` set the given name is the only one tried; otherwise a number is added on -/// collision. Returns true if a file was written. -bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, - bool exact) { +/// collision. Returns nullptr, having logged why, if there is no file. `path` is where it is. +FILE *create_snapshot_file(const std::string &name, bool exact, std::string &path) { const std::string dir = snapshot_dir(); std::error_code ec; std::filesystem::create_directories(dir, ec); if (ec) { ESP_LOGE(TAG, "Could not create snapshot directory %s: %s", dir.c_str(), ec.message().c_str()); - return false; + return nullptr; } // O_EXCL guarantees we never write over a file that is already there. - std::string path; int fd = -1; for (unsigned attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) { path = dir + "/" + (attempt == 0 ? name : add_suffix(name, attempt)); @@ -151,18 +157,18 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro break; if (errno != EEXIST) { ESP_LOGE(TAG, "Could not create %s: %s", path.c_str(), strerror(errno)); - return false; + return nullptr; } if (exact) { // The caller asked for this exact name, so silently writing somewhere else would be worse // than failing - a test asserting on the path would pick up a stale file. ESP_LOGE(TAG, "Snapshot %s already exists, not overwriting", path.c_str()); - return false; + return nullptr; } } if (fd < 0) { ESP_LOGE(TAG, "Could not find an unused name for %s in %s", name.c_str(), dir.c_str()); - return false; + return nullptr; } FILE *file = fdopen(fd, "wb"); @@ -170,9 +176,14 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro ESP_LOGE(TAG, "Could not open %s: %s", path.c_str(), strerror(errno)); ::close(fd); ::unlink(path.c_str()); - return false; } - bool ok = write_bmp(file, pixels, width, height, row_stride); + return file; +} + +/// Close a file that is meant to be finished. `ok` says whether everything written to it went well. +/// If not, or if closing shows that the data did not all get out, say why and remove the file: a +/// truncated one would block a retry under the same name. Returns true if the file is complete. +bool close_snapshot_file(FILE *file, const std::string &path, bool ok) { int saved_errno = ok ? 0 : errno; // Closing can fail in its own right - the last of the data is still on its way out. if (fclose(file) != 0) { @@ -182,18 +193,114 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro } if (!ok) { ESP_LOGE(TAG, "Could not write %s: %s", path.c_str(), strerror(saved_errno)); - // Leave no truncated file behind - it would block a retry under the same name. ::unlink(path.c_str()); - return false; } + return ok; +} + +/// Write pixels to a new BMP file. See create_snapshot_file() for `name` and `exact`. +bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, + bool exact) { + std::string path; + FILE *file = create_snapshot_file(name, exact, path); + if (file == nullptr) + return false; + if (!close_snapshot_file(file, path, write_bmp(file, pixels, width, height, row_stride))) + return false; ESP_LOGI(TAG, "Snapshot written to %s", path.c_str()); return true; } +/// Work out the file name for a request. A name of the caller's own is made safe and is the only +/// one tried (`exact`); otherwise one is made up from the prefix and the current time. +std::string choose_name(const char *filename, const char *prefix, const char *extension, bool &exact) { + std::string name; + exact = false; + if (filename != nullptr) { + bool name_changed = false; + name = sanitise_filename(filename, extension, &name_changed); + exact = !name.empty(); + if (name_changed) { + ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, + name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); + } + } + if (!name.empty()) + return name; + + struct timespec now {}; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) + now = {}; + struct tm tm_buf {}; + if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) + tm_buf = {}; + char stamp[32]{}; + // ::strftime to be sure of the one from ; display has an unrelated member of that name + if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) + snprintf(stamp, sizeof(stamp), "unknown-time"); + char buffer[MAX_NAME_LENGTH]; + int written = snprintf(buffer, sizeof(buffer), "%s-%s-%03ld%s", prefix, stamp, now.tv_nsec / 1000000, extension); + if (written < 0 || static_cast(written) >= sizeof(buffer)) { + ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); + snprintf(buffer, sizeof(buffer), "snapshot%s", extension); + } + return buffer; +} + } // namespace +/// An animation being written: the file, and where in the timetable it has got to. +struct Snapshot::Recording { + Recording(FILE *file, std::string path, int width, int height, uint32_t frames, float frame_rate) + : file(file), + path(std::move(path)), + gif(file, width, height), + row_stride(padded_row_size(width)), + pixels(std::make_unique(this->row_stride * height)), + frames(frames), + frame_rate(frame_rate) {} + ~Recording() { + // The file is still open only if the recording did not finish, so do not leave a partial one behind. + if (this->file != nullptr) { + fclose(this->file); + ::unlink(this->path.c_str()); + } + } + + /// When the frame with this number is due, in milliseconds after the recording began. + uint32_t due_ms(uint32_t frame) const { + return static_cast(std::lround(frame * 1000.0 / this->frame_rate)); + } + /// How long to show the frame just taken, in hundredths of a second, as a GIF file counts time. + /// Each frame's time is worked out from the start rather than by rounding its own length, so a + /// rate that does not divide evenly into hundredths still averages out to what was asked for. + unsigned delay_centiseconds() const { + auto centiseconds_at = [this](uint32_t frame) { return std::lround(frame * 100.0 / this->frame_rate); }; + return static_cast(centiseconds_at(this->frames_done + 1) - centiseconds_at(this->frames_done)); + } + + FILE *file; + std::string path; + GifWriter gif; + size_t row_stride; + std::unique_ptr pixels; + uint32_t frames; + float frame_rate; + uint32_t frames_done{0}; + uint32_t start_ms{0}; +}; + +Snapshot::Snapshot() = default; +Snapshot::~Snapshot() = default; + // helper function since ESP_LOGW is disallowed in a header file -void Snapshot::log_action_failed() { ESP_LOGW(TAG, "snapshot.take did not write a file"); } +void Snapshot::take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate) { + const char *name = filename[0] != '\0' ? filename : nullptr; + const bool ok = frames == 0 ? this->take_snapshot(name) : this->take_animation(name, frames, frame_rate); + if (!ok) { + ESP_LOGW(TAG, "snapshot.take did not write a file"); + } +} bool Snapshot::take_snapshot(const char *filename) { const int width = this->snapshot_width(); @@ -203,46 +310,80 @@ bool Snapshot::take_snapshot(const char *filename) { return false; } - std::string name; - bool exact = false; - if (filename != nullptr) { - bool name_changed = false; - name = sanitise_filename(filename, &name_changed); - exact = !name.empty(); - if (name_changed) { - ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, - name.empty() ? "a name made from the time" : name.c_str()); - } - } - if (name.empty()) { - struct timespec now {}; - if (clock_gettime(CLOCK_REALTIME, &now) != 0) - now = {}; - struct tm tm_buf {}; - if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) - tm_buf = {}; - char stamp[32]{}; - // ::strftime to be sure of the one from ; display has an unrelated member of that name - if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) - snprintf(stamp, sizeof(stamp), "unknown-time"); - char buffer[MAX_NAME_LENGTH]; - int written = - snprintf(buffer, sizeof(buffer), "%s-%s-%03ld.bmp", this->snapshot_prefix_, stamp, now.tv_nsec / 1000000); - if (written < 0 || static_cast(written) >= sizeof(buffer)) { - ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); - snprintf(buffer, sizeof(buffer), "snapshot.bmp"); - } - name = buffer; - } + bool exact; + const std::string name = choose_name(filename, this->snapshot_prefix_, ".bmp", exact); // Rows are padded out to a multiple of four bytes, as the file wants them, so each one can be // written straight from the buffer. Zeroed on allocation, which is what the padding must be. - const size_t row_stride = bmp_row_size(width); + const size_t row_stride = padded_row_size(width); auto pixels = std::make_unique(row_stride * height); if (!this->capture_bgr(pixels.get(), row_stride)) return false; return write_snapshot_file(pixels.get(), width, height, row_stride, name, exact); } +bool Snapshot::take_animation(const char *filename, uint32_t frames, float frame_rate) { + if (this->recording_ != nullptr) { + ESP_LOGW(TAG, "Already recording %s, not starting another", this->recording_->path.c_str()); + return false; + } + if (frames == 0 || !(frame_rate > 0)) { + ESP_LOGE(TAG, "Animation requested with %u frames at %.1f frames a second", frames, frame_rate); + return false; + } + const int width = this->snapshot_width(); + const int height = this->snapshot_height(); + if (width <= 0 || height <= 0 || width > GIF_MAX_SIZE || height > GIF_MAX_SIZE) { + ESP_LOGE(TAG, "Animation requested but the display is %dx%d, and a GIF holds up to %d", width, height, + GIF_MAX_SIZE); + return false; + } + + bool exact; + const std::string name = choose_name(filename, this->snapshot_prefix_, ".gif", exact); + std::string path; + FILE *file = create_snapshot_file(name, exact, path); + if (file == nullptr) + return false; + auto recording = std::make_unique(file, path, width, height, frames, frame_rate); + if (!recording->gif.write_header()) { + recording->file = nullptr; + close_snapshot_file(file, path, false); + return false; + } + ESP_LOGI(TAG, "Recording %u frames at %.1f frames a second to %s", frames, frame_rate, path.c_str()); + this->recording_ = std::move(recording); + this->recording_->start_ms = millis(); + return this->record_frame_(); +} + +bool Snapshot::record_frame_() { + Recording &recording = *this->recording_; + if (!this->capture_bgr(recording.pixels.get(), recording.row_stride)) { + // capture_bgr() has said why. Resetting removes the half-finished file. + this->recording_.reset(); + return false; + } + + bool ok = recording.gif.write_frame(recording.pixels.get(), recording.row_stride, recording.delay_centiseconds()); + recording.frames_done++; + const bool finished = recording.frames_done == recording.frames; + if (ok && finished) + ok = recording.gif.write_trailer(); + if (!ok || finished) { + ok = close_snapshot_file(std::exchange(recording.file, nullptr), recording.path, ok); + if (ok) { + ESP_LOGI(TAG, "Animation written to %s", recording.path.c_str()); + } + this->recording_.reset(); + return ok; + } + + // Aim for the time each frame is due, not a fixed gap after the last, so delays do not add up. + const int32_t wait = static_cast(recording.start_ms + recording.due_ms(recording.frames_done) - millis()); + App.scheduler.set_timeout(this, wait > 0 ? static_cast(wait) : 0, [this]() { this->record_frame_(); }); + return true; +} + } // namespace esphome::snapshot #endif diff --git a/esphome/components/snapshot/snapshot.h b/esphome/components/snapshot/snapshot.h index bb670e639f0..4d923f6475e 100644 --- a/esphome/components/snapshot/snapshot.h +++ b/esphome/components/snapshot/snapshot.h @@ -1,10 +1,10 @@ #pragma once #ifdef USE_HOST -#include "esphome/core/automation.h" #include #include +#include #include // Directory snapshots are written to. Normally set by codegen to a folder under .esphome; the @@ -18,11 +18,14 @@ namespace esphome::snapshot { /// Base for anything that can hand over the picture it is showing so it can be written to a file. /// /// A subclass says how big the picture is and fills in the pixels. Everything else - picking a -/// name, staying inside the snapshot directory, not writing over anything, and encoding the file - -/// is done here, so every component that can take a snapshot behaves the same way. +/// name, staying inside the snapshot directory, not writing over anything, encoding the file, and +/// timing the frames of an animation - is done here, so every component that can take a snapshot +/// behaves the same way. class Snapshot { public: - virtual ~Snapshot() = default; + // Declared here and defined where Recording is complete, which unique_ptr needs. + Snapshot(); + virtual ~Snapshot(); /// Set the word generated names start with. Codegen passes the component id, so with more than /// one display in a device it is clear which one a file came from. @@ -34,8 +37,20 @@ class Snapshot { /// already there is never written over. Returns true if a file was written. bool take_snapshot(const char *filename); - /// Log that an action-triggered snapshot did not write a file. - static void log_action_failed(); + /// Record what is shown as an animated GIF file in the snapshot directory. + /// + /// The first frame is taken now and the rest follow at `frame_rate` frames a second, in the + /// background. Names work as for take_snapshot(), with ".gif" in place of ".bmp". Only one + /// recording can run at a time. Returns true if the recording started. + bool take_animation(const char *filename, uint32_t frames, float frame_rate); + + /// Take a snapshot for an automation: an empty name means a generated one, and a snapshot + /// that wrote nothing is logged. A `frames` of 0 takes a single picture; otherwise an + /// animation is recorded. + void take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate); + void take_snapshot_or_log(const std::string &filename, uint32_t frames, float frame_rate) { + this->take_snapshot_or_log(filename.c_str(), frames, frame_rate); + } protected: /// Width of the picture in pixels. @@ -48,23 +63,15 @@ class Snapshot { virtual bool capture_bgr(uint8_t *dest, size_t row_stride) = 0; const char *snapshot_prefix_{"snapshot"}; -}; -template class SnapshotAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, filename) + private: + struct Recording; - protected: - void play(const Ts &...x) override { - bool ok; - if (this->filename_.has_value()) { - ok = this->parent_->take_snapshot(this->filename_.value(x...).c_str()); - } else { - ok = this->parent_->take_snapshot(nullptr); - } - if (!ok) - this->parent_->log_action_failed(); - } + /// Add a frame to the recording and arrange for the next one. Returns false if the recording + /// had to be given up. + bool record_frame_(); + + std::unique_ptr recording_; }; } // namespace esphome::snapshot diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 0d4284f1456..cec3b3498a7 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -59,13 +59,15 @@ int BSDSocketImpl::close() { int BSDSocketImpl::setblocking(bool blocking) { int fl = ::fcntl(this->fd_, F_GETFL, 0); + if (fl < 0) { + return fl; + } if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - ::fcntl(this->fd_, F_SETFL, fl); - return 0; + return ::fcntl(this->fd_, F_SETFL, fl); } size_t BSDSocketImpl::getpeername_to(std::span buf) { diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 528d201799b..f4597f2522f 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -205,6 +205,13 @@ static constexpr size_t SOCKADDR_STR_LEN = 46; // INET6_ADDRSTRLEN static constexpr size_t SOCKADDR_STR_LEN = 16; // INET_ADDRSTRLEN #endif +/// Outcome of polling a non-blocking connect(); see socket::poll_connect(). +enum class ConnectPollResult : uint8_t { + CONNECT_POLL_RESULT_PENDING, + CONNECT_POLL_RESULT_CONNECTED, + CONNECT_POLL_RESULT_ERROR, +}; + } // namespace esphome::socket #endif diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 8d00dbede2a..b411445b98a 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -48,8 +48,33 @@ static const char *const TAG = "socket"; #ifdef USE_ESP8266 // optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +// Let SYS run so queued WiFi traffic reaches lwip; CONT and SYS are cooperative +static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); } +#else +static inline void yield_to_sys() {} #endif +// errno for a failed tcp_* call +static int lwip_err_to_errno(err_t err) { + switch (err) { + case ERR_MEM: + return ENOMEM; + case ERR_BUF: + return EAGAIN; // transient, e.g. no free local port + case ERR_RTE: + return EHOSTUNREACH; // no route, e.g. no address yet + case ERR_VAL: + case ERR_ARG: + return EINVAL; + case ERR_USE: + return EADDRINUSE; + case ERR_ISCONN: + return EISCONN; + default: + return EIO; + } +} + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -62,8 +87,8 @@ static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; // Must be called before destroying the object that tcp_arg points to — // tcp_abort() triggers the err callback synchronously, which would // otherwise call back into a partially-destroyed object. -// tcp_sent/tcp_poll are not cleared because this implementation -// never registers them. +// tcp_sent/tcp_poll are never registered and the connect callback cannot +// fire after abort or close, so neither is cleared. static void pcb_detach_abort(struct tcp_pcb *pcb) { tcp_arg(pcb, nullptr); tcp_recv(pcb, nullptr); @@ -76,8 +101,7 @@ static void pcb_detach_abort(struct tcp_pcb *pcb) { // After tcp_close(), the PCB remains alive during the TCP close handshake // (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP // would call recv/err on a destroyed socket object, corrupting the heap. -// tcp_sent/tcp_poll are not cleared because this implementation -// never registers them. +// Callbacks are left as in pcb_detach_abort(). // Returns ERR_OK on success; on failure the PCB is aborted instead. static err_t pcb_detach_close(struct tcp_pcb *pcb) { tcp_arg(pcb, nullptr); @@ -101,67 +125,51 @@ LWIPRawCommon::~LWIPRawCommon() { } } +bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const { + if (name == nullptr) { + errno = EINVAL; + return false; + } +#if LWIP_IPV6 + if (this->family_ == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) { + errno = EINVAL; + return false; + } + auto *addr6 = reinterpret_cast(name); + *port = ntohs(addr6->sin6_port); + inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr); + // ANY lets bind() accept both families; connect() picks the concrete type + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); + return true; + } +#endif + if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) { + errno = EINVAL; + return false; + } + auto *addr4 = reinterpret_cast(name); + *port = ntohs(addr4->sin_port); + ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr); + return true; +} + int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; } - if (name == nullptr) { - errno = EINVAL; - return -1; - } ip_addr_t ip; - in_port_t port; -#if LWIP_IPV6 - if (this->family_ == AF_INET) { - if (addrlen < sizeof(sockaddr_in)) { - errno = EINVAL; - return -1; - } - auto *addr4 = reinterpret_cast(name); - port = ntohs(addr4->sin_port); - ip.type = IPADDR_TYPE_V4; - ip.u_addr.ip4.addr = addr4->sin_addr.s_addr; - LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip4addr_ntoa(&ip.u_addr.ip4), port); - } else if (this->family_ == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) { - errno = EINVAL; - return -1; - } - auto *addr6 = reinterpret_cast(name); - port = ntohs(addr6->sin6_port); - ip.type = IPADDR_TYPE_ANY; - memcpy(&ip.u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); - LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip6addr_ntoa(&ip.u_addr.ip6), port); - } else { - errno = EINVAL; + uint16_t port; + if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) { return -1; } -#else - if (this->family_ != AF_INET) { - errno = EINVAL; - return -1; - } - auto *addr4 = reinterpret_cast(name); - port = ntohs(addr4->sin_port); - ip.addr = addr4->sin_addr.s_addr; - LWIP_LOG("tcp_bind(%p ip=%u port=%u)", this->pcb_, ip.addr, port); -#endif + LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); err_t err = tcp_bind(this->pcb_, &ip, port); - if (err == ERR_USE) { - LWIP_LOG(" -> err ERR_USE"); - errno = EADDRINUSE; - return -1; - } - if (err == ERR_VAL) { - LWIP_LOG(" -> err ERR_VAL"); - errno = EINVAL; - return -1; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = EIO; + errno = lwip_err_to_errno(err); return -1; } return 0; @@ -178,7 +186,7 @@ int LWIPRawCommon::close() { this->pcb_ = nullptr; if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = err == ERR_MEM ? ENOMEM : EIO; + errno = lwip_err_to_errno(err); return -1; } return 0; @@ -205,7 +213,7 @@ int LWIPRawCommon::shutdown(int how) { err_t err = tcp_shutdown(this->pcb_, shut_rx, shut_tx); if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = err == ERR_MEM ? ENOMEM : EIO; + errno = lwip_err_to_errno(err); return -1; } return 0; @@ -425,7 +433,82 @@ void LWIPRawImpl::s_err_fn(void *arg, err_t err) { // ERR_ABRT: aborted through tcp_abort or TCP timer auto *arg_this = reinterpret_cast(arg); ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err); + if (arg_this->connect_err_ == EINPROGRESS) { + // Refused (RST) or SYN retries exhausted; written before pcb_ so + // poll_connect() never sees a dead pcb without its reason + arg_this->connect_err_ = err == ERR_RST ? ECONNREFUSED : ETIMEDOUT; + } arg_this->pcb_ = nullptr; + esphome::wake_loop_any_context(); +} + +err_t LWIPRawImpl::s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err) { + // LWIP CALLBACK, same constraints as s_err_fn; err is always ERR_OK + auto *arg_this = reinterpret_cast(arg); + arg_this->connect_err_ = EISCONN; + esphome::wake_loop_any_context(); + return ERR_OK; +} + +int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (this->connect_err_ == EINPROGRESS || this->connect_err_ == EISCONN) { + errno = this->connect_err_ == EINPROGRESS ? EALREADY : EISCONN; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) { + return -1; + } +#if LWIP_IPV6 + // tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped + if (IP_IS_ANY_TYPE_VAL(ip)) { + if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) { + unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip)); + IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4); + } else { + IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6); + } + } +#endif + LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); + err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn); + if (err != ERR_OK) { + LWIP_LOG(" -> err %d", err); + errno = lwip_err_to_errno(err); + return -1; + } + this->connect_err_ = EINPROGRESS; + errno = EINPROGRESS; + return -1; +} + +ConnectPollResult LWIPRawImpl::poll_connect(int &err_out) const { + // pcb_ first; see the ordering note on the declaration + if (this->pcb_ == nullptr) { + // Only a recorded connect failure carries its own reason + const bool failed = this->connect_err_ == ECONNREFUSED || this->connect_err_ == ETIMEDOUT; + err_out = failed ? this->connect_err_ : ECONNRESET; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + switch (this->connect_err_) { + case EINPROGRESS: + yield_to_sys(); // so the SYN-ACK is processed between polls + return ConnectPollResult::CONNECT_POLL_RESULT_PENDING; + case EISCONN: + return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED; + case 0: + err_out = EINVAL; // no connect was started + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + default: + err_out = this->connect_err_; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } } err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) { @@ -540,14 +623,11 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { -#ifdef USE_ESP8266 - // Would block: yield to SYS so queued WiFi RX reaches lwip and this read - // may succeed. Without this, inbound segments can sit unprocessed for - // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + // Let queued WiFi RX reach lwip first; otherwise inbound segments can + // sit unprocessed for seconds while the main loop polls if (this->waiting_for_data_()) { - optimistic_yield(ESP8266_YIELD_INTERVAL_US); + yield_to_sys(); } -#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -636,12 +716,10 @@ int LWIPRawImpl::internal_output_() { return -1; } } -#ifdef USE_ESP8266 // Flushed: yield to SYS so the queued segments reach the WiFi driver // instead of waiting seconds for an unrelated SYS slot. Callers only get // here after a successful tcp_write, so idle paths never yield. - optimistic_yield(ESP8266_YIELD_INTERVAL_US); -#endif + yield_to_sys(); return 0; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 917b5b2f7a2..ed8057e289a 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -50,6 +50,8 @@ class LWIPRawCommon { protected: int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch + bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const; // Member ordering optimized to minimize padding on 32-bit systems struct tcp_pcb *pcb_; @@ -58,7 +60,14 @@ class LWIPRawCommon { bool nodelay_ = false; sa_family_t family_ = 0; uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s) + // 0 before connect(), EINPROGRESS while pending, EISCONN once established, + // else the failure errno the callbacks recorded; fills the padding byte + uint8_t connect_err_ = 0; + static_assert(EINPROGRESS < 256 && EISCONN < 256 && ECONNREFUSED < 256 && ECONNRESET < 256 && ETIMEDOUT < 256, + "connect_err_ stores errno values in a byte"); }; +// The connect state must stay in the padding so no socket pays RAM for it +static_assert(sizeof(LWIPRawCommon) == sizeof(struct tcp_pcb *) + 4, "LWIPRawCommon grew past one word of flags"); /// Connected socket implementation for LWIP raw TCP. /// No virtual methods — callers always use the concrete type. @@ -83,6 +92,12 @@ class LWIPRawImpl : public LWIPRawCommon { errno = EOPNOTSUPP; return -1; } + /// Non-blocking: returns -1/EINPROGRESS once the SYN is queued, see poll_connect(). + /// addr must match the socket family; an IPv4 peer on AF_INET6 arrives v4-mapped. + int connect(const struct sockaddr *addr, socklen_t addrlen); + // Unlocked like ready(): the callbacks write the error byte before pcb_, + // so a torn read only costs one extra poll + ConnectPollResult poll_connect(int &err_out) const; ssize_t read(void *buf, size_t len); ssize_t readv(const struct iovec *iov, int iovcnt); ssize_t recvfrom(void *, size_t, sockaddr *, socklen_t *) { @@ -120,6 +135,7 @@ class LWIPRawImpl : public LWIPRawCommon { static void s_err_fn(void *arg, err_t err); static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); + static err_t s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err); protected: // True when the socket could receive data but none has arrived yet. @@ -137,6 +153,9 @@ class LWIPRawImpl : public LWIPRawCommon { size_t rx_buf_offset_ = 0; bool rx_closed_ = false; }; +// rx_buf_, rx_buf_offset_, then rx_closed_ padded to a word +static_assert(sizeof(LWIPRawImpl) == sizeof(LWIPRawCommon) + sizeof(pbuf *) + sizeof(size_t) + 4, + "LWIPRawImpl layout changed"); /// Listening socket implementation for LWIP raw TCP. /// Separate from LWIPRawImpl — no virtual dispatch needed. diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index a6bd639c10f..f87c5377e4e 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -49,13 +49,15 @@ int LwIPSocketImpl::close() { int LwIPSocketImpl::setblocking(bool blocking) { int fl = lwip_fcntl(this->fd_, F_GETFL, 0); + if (fl < 0) { + return fl; + } if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - lwip_fcntl(this->fd_, F_SETFL, fl); - return 0; + return lwip_fcntl(this->fd_, F_SETFL, fl); } size_t LwIPSocketImpl::getpeername_to(std::span buf) { diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 212da80312b..f2d428af772 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -2,6 +2,9 @@ #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) #include #include +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#include +#endif #include #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -165,7 +168,10 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ #else // Use LWIP-specific functions ip6_addr_t ip6; - inet6_aton(ip_address, &ip6); + if (inet6_aton(ip_address, &ip6) == 0) { + errno = EINVAL; + return 0; + } memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); #endif return sizeof(sockaddr_in6); @@ -185,12 +191,57 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ return 0; } #else - server->sin_addr.s_addr = inet_addr(ip_address); + // inet_aton() reports failure separately from the address, so 255.255.255.255 needs no special case + if (inet_aton(ip_address, &server->sin_addr) == 0) { + errno = EINVAL; + return 0; + } #endif server->sin_port = htons(port); return sizeof(sockaddr_in); } +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +ConnectPollResult poll_connect(Socket &sock, int &err_out) { + int fd = sock.get_fd(); + if (fd < 0 || fd >= FD_SETSIZE) { + // FD_SET on either is undefined behavior + err_out = EBADF; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + // Connect completion is a write event; the main loop only selects on reads + fd_set writefds; + FD_ZERO(&writefds); + FD_SET(fd, &writefds); + struct timeval tv = {0, 0}; +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS + // LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly + int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv); +#else + // Global-scope select: the entity namespace esphome::select shadows it here + int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv); +#endif + if (ret < 0) { + err_out = errno; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + if (ret == 0) { + return ConnectPollResult::CONNECT_POLL_RESULT_PENDING; + } + int error = 0; + socklen_t len = sizeof(error); + if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) { + err_out = errno; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + if (error != 0) { + err_out = error; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED; +} +#endif + socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) { #if USE_NETWORK_IPV6 if (addrlen < sizeof(sockaddr_in6)) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index eb8870786d0..8ce392ff1dd 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -145,6 +145,14 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +/// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or +/// errno) on fd implementations and the failure the callbacks recorded on raw lwip. +#ifdef USE_SOCKET_IMPL_LWIP_TCP +inline ConnectPollResult poll_connect(Socket &sock, int &err_out) { return sock.poll_connect(err_out); } +#else +ConnectPollResult poll_connect(Socket &sock, int &err_out); +#endif + /// Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf); diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index d2175340415..a28e91dfd52 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -11,8 +11,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DECIBEL, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -27,8 +25,6 @@ CONF_RMS = "rms" sound_level_ns = cg.esphome_ns.namespace("sound_level") SoundLevelComponent = sound_level_ns.class_("SoundLevelComponent", cg.Component) -StartAction = sound_level_ns.class_("StartAction", automation.Action) -StopAction = sound_level_ns.class_("StopAction", automation.Action) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -92,18 +88,9 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "sound_level.start", SOUND_LEVEL_ACTION_SCHEMA, automation.ApplyCall("start()") ) -@automation.register_action( - "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "sound_level.stop", SOUND_LEVEL_ACTION_SCHEMA, automation.ApplyCall("stop()") ) -async def sound_level_action_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]) - return var diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 94c18421baf..008b8d62262 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -7,7 +7,6 @@ #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::sound_level { @@ -59,16 +58,6 @@ class SoundLevelComponent final : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - } // namespace esphome::sound_level #endif diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 98b5abe58cb..d0a743543e5 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -18,25 +18,6 @@ Speaker = speaker_ns.class_("Speaker") PlayAction = speaker_ns.class_( "PlayAction", automation.Action, cg.Parented.template(Speaker) ) -StopAction = speaker_ns.class_( - "StopAction", automation.Action, cg.Parented.template(Speaker) -) -FinishAction = speaker_ns.class_( - "FinishAction", automation.Action, cg.Parented.template(Speaker) -) -VolumeSetAction = speaker_ns.class_( - "VolumeSetAction", automation.Action, cg.Parented.template(Speaker) -) -MuteOnAction = speaker_ns.class_( - "MuteOnAction", automation.Action, cg.Parented.template(Speaker) -) -MuteOffAction = speaker_ns.class_( - "MuteOffAction", automation.Action, cg.Parented.template(Speaker) -) - - -IsPlayingCondition = speaker_ns.class_("IsPlayingCondition", automation.Condition) -IsStoppedCondition = speaker_ns.class_("IsStoppedCondition", automation.Condition) async def setup_speaker_core_(var, config): @@ -62,12 +43,6 @@ SPEAKER_AUTOMATION_SCHEMA = automation.maybe_simple_id( ) -async def speaker_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - @automation.register_action( "speaker.play", PlayAction, @@ -96,25 +71,26 @@ async def speaker_play_action(config, action_id, template_arg, args): return var -automation.register_action( - "speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -)(speaker_action) -automation.register_action( - "speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -)(speaker_action) +for _name, _call in ( + ("speaker.stop", "stop()"), + ("speaker.finish", "finish()"), + ("speaker.mute_on", "set_mute_state(true)"), + ("speaker.mute_off", "set_mute_state(false)"), +): + automation.register_apply_action( + _name, SPEAKER_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) -automation.register_condition( - "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA -)(speaker_action) - -automation.register_condition( - "speaker.is_stopped", IsStoppedCondition, SPEAKER_AUTOMATION_SCHEMA -)(speaker_action) +automation.register_apply_condition( + "speaker.is_playing", SPEAKER_AUTOMATION_SCHEMA, "is_running()" +) +automation.register_apply_condition( + "speaker.is_stopped", SPEAKER_AUTOMATION_SCHEMA, "is_stopped()" +) -@automation.register_action( +automation.register_apply_action( "speaker.volume_set", - VolumeSetAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Speaker), @@ -122,28 +98,8 @@ automation.register_condition( }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), ) -async def speaker_volume_set_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) - cg.add(var.set_volume(volume)) - return var - - -@automation.register_action( - "speaker.mute_off", - MuteOffAction, - SPEAKER_AUTOMATION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -) -async def speaker_mute_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 443588a04c1..a44b79f05e3 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -38,49 +38,4 @@ template class PlayAction final : public Action, public P } data_; }; -template class VolumeSetAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, volume) - void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } -}; - -template class MuteOnAction final : public Action { - public: - explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} - - void play(const Ts &...x) override { this->speaker_->set_mute_state(true); } - - protected: - Speaker *speaker_; -}; - -template class MuteOffAction final : public Action { - public: - explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} - - void play(const Ts &...x) override { this->speaker_->set_mute_state(false); } - - protected: - Speaker *speaker_; -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class FinishAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->finish(); } -}; - -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class IsStoppedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_stopped(); } -}; - } // namespace esphome::speaker diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 90eb19d73df..78892bff226 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,7 +1,5 @@ """Speaker Media Player Setup.""" -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import ( @@ -24,7 +22,6 @@ from esphome.const import ( CONF_BUFFER_SIZE, CONF_FILES, CONF_FORMAT, - CONF_ID, CONF_NUM_CHANNELS, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, @@ -33,9 +30,6 @@ from esphome.const import ( CONF_TASK_STACK_IN_PSRAM, ) -_LOGGER = logging.getLogger(__name__) - - AUTO_LOAD = ["audio"] DEPENDENCIES = ["network"] @@ -44,7 +38,7 @@ DOMAIN = "media_player" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -68,11 +62,6 @@ AUDIO_PIPELINE_TYPE_ENUM = { "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, } -PlayOnDeviceMediaAction = speaker_ns.class_( - "PlayOnDeviceMediaAction", - automation.Action, - cg.Parented.template(SpeakerMediaPlayer), -) StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) ) @@ -103,15 +92,6 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Remove before 2026.10.0 - if CONF_CODEC_SUPPORT_ENABLED in config: - _LOGGER.warning( - "'%s' is deprecated and will be removed in 2026.10.0. " - "Codec support is now automatically determined from the pipeline " - "'format' setting. Set format to 'NONE' to enable all codecs.", - CONF_CODEC_SUPPORT_ENABLED, - ) - # Request codecs based on pipeline formats. Codecs needed by local files are # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( @@ -151,8 +131,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - # Remove before 2026.10.0 - cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), + # Removed in 2026.10.0 - kept to provide helpful error message + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid( + "The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n" + "Codec support is now determined from the pipeline 'format' setting.\n" + "Set 'format: NONE' on the pipeline to enable all codecs." + ), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, @@ -249,9 +233,8 @@ async def to_code(config): audio_file.generate_audio_file_code(file_config) -@automation.register_action( +automation.register_apply_action( "media_player.speaker.play_on_device_media_file", - PlayOnDeviceMediaAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SpeakerMediaPlayer), @@ -261,17 +244,18 @@ async def to_code(config): }, key=CONF_MEDIA_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {}, {})", + ( + # The file is a use_id of a global AudioFile pointer; global-scope qualified + # so a trigger arg with the same name cannot shadow it. + ( + CONF_MEDIA_FILE, + audio.AudioFile.operator("ptr"), + lambda _, value: f"::{value}", + ), + (CONF_ANNOUNCEMENT, cg.bool_), + (CONF_ENQUEUE, cg.bool_), + ), + ), ) -async def play_on_device_media_media_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - media_file = await cg.get_variable(config[CONF_MEDIA_FILE]) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - enqueue = await cg.templatable(config[CONF_ENQUEUE], args, cg.bool_) - - template_ = await cg.templatable(media_file, args, audio.AudioFile.operator("ptr")) - cg.add(var.set_audio_file(template_)) - cg.add(var.set_announcement(announcement)) - cg.add(var.set_enqueue(enqueue)) - return var diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 010f0c50b33..509984cfa29 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() { if (!this->is_playing_) { // The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks if (this->read_task_.is_created() || this->decode_task_.is_created()) { - this->read_task_.deallocate(); - this->decode_task_.deallocate(); + // Both are attempted every time; a task that is still running on the other core is freed by a + // subsequent call, and freeing an already freed task succeeds without doing anything + bool read_task_freed = this->read_task_.deallocate(); + bool decode_task_freed = this->decode_task_.deallocate(); + if (!read_task_freed || !decode_task_freed) { + // A task is still running on the other core, so keep the pipeline in its current state and try + // again on the next call + return AudioPipelineState::PLAYING; + } if (this->hard_stop_) { // Stop command was sent, so immediately end the playback this->speaker_->stop(); @@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) { if (err == ESP_OK) { size_t file_ring_buffer_size = this_pipeline->buffer_size_; - std::shared_ptr temp_ring_buffer; + std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; } - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { - reader->add_sink(this_pipeline->raw_file_ring_buffer_); + err = reader->add_sink(temp_ring_buffer); } } @@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) { make_unique(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_); esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_); - decoder->add_source(this_pipeline->raw_file_ring_buffer_); + if (err == ESP_OK) { + err = decoder->add_source(this_pipeline->raw_file_ring_buffer_); + } if (err != ESP_OK) { // Send specific error message diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h deleted file mode 100644 index f9e21279939..00000000000 --- a/esphome/components/speaker/media_player/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "speaker_media_player.h" - -#ifdef USE_ESP32 - -#include "esphome/components/audio/audio.h" -#include "esphome/core/automation.h" - -namespace esphome::speaker { - -template -class PlayOnDeviceMediaAction final : public Action, public Parented { - TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) - TEMPLATABLE_VALUE(bool, announcement) - TEMPLATABLE_VALUE(bool, enqueue) - void play(const Ts &...x) override { - this->parent_->play_file(this->audio_file_.value(x...), this->announcement_.value(x...), - this->enqueue_.value(x...)); - } -}; - -} // namespace esphome::speaker - -#endif diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index fe994f440df..06525235d47 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -154,6 +154,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); + this->announcement_item_failed_ = false; if (media_command.file.has_value()) { this->announcement_pipeline_->start_file(playlist_item.file.value()); } else if (media_command.url.has_value()) { @@ -167,6 +168,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause this->cancel_timeout("next_media"); this->media_playlist_.clear(); + this->media_item_failed_ = false; if (this->is_paused_) { // If paused, stop the media pipeline and unpause it after confirming its stopped. This avoids playing a // short segment of the paused file before starting the new one. @@ -236,6 +238,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->announcement_pipeline_ != nullptr) { this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); + this->announcement_item_failed_ = false; this->announcement_pipeline_->stop(); this->unpause_announcement_remaining_ = 3; this->set_interval("unpause_ann", 50, [this]() { @@ -251,6 +254,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->media_pipeline_ != nullptr) { this->cancel_timeout("next_media"); this->media_playlist_.clear(); + this->media_item_failed_ = false; this->stop_and_unpause_media_(); } } @@ -351,8 +355,10 @@ void SpeakerMediaPlayer::loop() { if (this->media_pipeline_state_ == AudioPipelineState::ERROR_READING) { ESP_LOGE(TAG, "The media pipeline's file reader encountered an error."); + this->media_item_failed_ = true; } else if (this->media_pipeline_state_ == AudioPipelineState::ERROR_DECODING) { ESP_LOGE(TAG, "The media pipeline's audio decoder encountered an error."); + this->media_item_failed_ = true; } AudioPipelineState old_announcement_pipeline_state = this->announcement_pipeline_state_; @@ -362,8 +368,10 @@ void SpeakerMediaPlayer::loop() { if (this->announcement_pipeline_state_ == AudioPipelineState::ERROR_READING) { ESP_LOGE(TAG, "The announcement pipeline's file reader encountered an error."); + this->announcement_item_failed_ = true; } else if (this->announcement_pipeline_state_ == AudioPipelineState::ERROR_DECODING) { ESP_LOGE(TAG, "The announcement pipeline's audio decoder encountered an error."); + this->announcement_item_failed_ = true; } if (this->announcement_pipeline_state_ != AudioPipelineState::STOPPED) { @@ -371,7 +379,12 @@ void SpeakerMediaPlayer::loop() { } else { if (!this->announcement_playlist_.empty()) { uint32_t timeout_ms = 0; - if (old_announcement_pipeline_state == AudioPipelineState::PLAYING) { + if (this->announcement_item_failed_) { + // Drop the item that failed, even with repeat enabled; otherwise it is restarted as soon as the pipeline + // stops, which after an error is usually on the next loop + this->announcement_item_failed_ = false; + this->announcement_playlist_.pop_front(); + } else if (old_announcement_pipeline_state == AudioPipelineState::PLAYING) { // Finished the current announcement file if (!this->announcement_repeat_one_) { // Pop item off the playlist if repeat is disabled @@ -399,6 +412,8 @@ void SpeakerMediaPlayer::loop() { } } } else { + // Nothing left to retry + this->announcement_item_failed_ = false; if (this->is_paused_) { #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { @@ -412,7 +427,12 @@ void SpeakerMediaPlayer::loop() { } else if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { if (!media_playlist_.empty()) { uint32_t timeout_ms = 0; - if (old_media_pipeline_state == AudioPipelineState::PLAYING) { + if (this->media_item_failed_) { + // Drop the item that failed, even with repeat enabled; otherwise it is restarted as soon as the pipeline + // stops. The flag also covers an error that happened while an announcement was playing. + this->media_item_failed_ = false; + this->media_playlist_.pop_front(); + } else if (old_media_pipeline_state == AudioPipelineState::PLAYING) { // Finished the current media file if (!this->media_repeat_one_) { // Pop item off the playlist if repeat is disabled @@ -439,6 +459,8 @@ void SpeakerMediaPlayer::loop() { } } } else { + // Nothing left to retry + this->media_item_failed_ = false; #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { this->state = media_player::MEDIA_PLAYER_STATE_IDLE; @@ -595,8 +617,11 @@ void SpeakerMediaPlayer::set_mute_state_(bool mute_state) { } void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit with in the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); if (this->media_speaker_ != nullptr) { this->media_speaker_->set_volume(bounded_volume); @@ -611,13 +636,6 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { this->save_volume_restore_state_(); } - // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001f) { - this->set_mute_state_(true); - } else { - this->set_mute_state_(false); - } - this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 6470fb925c2..889aedbc9ac 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -125,11 +125,15 @@ class SpeakerMediaPlayer final : public Component, optional media_format_; AudioPipelineState media_pipeline_state_{AudioPipelineState::STOPPED}; bool media_repeat_one_{false}; + // Set when the media pipeline reports an error, consumed when it stops, so the failed item is dropped + bool media_item_failed_{false}; uint32_t media_playlist_delay_ms_{0}; optional announcement_format_; AudioPipelineState announcement_pipeline_state_{AudioPipelineState::STOPPED}; bool announcement_repeat_one_{false}; + // Set when the announcement pipeline reports an error, consumed when it stops, so the failed item is dropped + bool announcement_item_failed_{false}; uint32_t announcement_playlist_delay_ms_{0}; QueueHandle_t media_control_command_queue_; diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index c89b6c588c7..01e9ca042e0 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -18,6 +18,9 @@ namespace esphome::speaker { +/// Volumes below this are treated as zero +static constexpr float SILENT_VOLUME_THRESHOLD = 0.001f; + enum State : uint8_t { STATE_STOPPED = 0, STATE_STARTING, @@ -65,13 +68,15 @@ class Speaker { bool is_running() const { return this->state_ == STATE_RUNNING; } bool is_stopped() const { return this->state_ == STATE_STOPPED; } - // Volume control is handled by a configured audio dac component. Individual speaker components can - // override and implement in software if an audio dac isn't available. + // Volume and mute are independent: changing one never alters the other's stored state. Volume control is + // handled by a configured audio dac component. Individual speaker components can override and implement in + // software if an audio dac isn't available. virtual void set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { this->audio_dac_->set_volume(volume); + this->apply_audio_dac_mute_(); } #endif }; @@ -80,13 +85,7 @@ class Speaker { virtual void set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } + this->apply_audio_dac_mute_(); #endif } virtual bool get_mute_state() { return this->mute_state_; } @@ -110,6 +109,23 @@ class Speaker { } protected: + /// @brief Whether the output should be silent: muted, or the volume is effectively zero. + /// Volume steps from media players can leave a positive value near float epsilon instead of exactly zero. + bool is_silent_() const { return this->mute_state_ || this->volume_ < SILENT_VOLUME_THRESHOLD; } + +#ifdef USE_AUDIO_DAC + /// @brief Uses the audio dac's mute as the silence mechanism, since a dac's minimum volume is often audible. + void apply_audio_dac_mute_() { + if (this->audio_dac_ == nullptr) + return; + if (this->is_silent_()) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } +#endif + State state_{STATE_STOPPED}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h deleted file mode 100644 index a03fa424777..00000000000 --- a/esphome/components/speaker_source/automation.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/core/automation.h" -#include "speaker_source_media_player.h" - -namespace esphome::speaker_source { - -template class SetPlaylistDelayAction final : public Action { - public: - explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(uint8_t, pipeline) - TEMPLATABLE_VALUE(uint32_t, delay) - - void play(const Ts &...x) override { - this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...)); - } - - protected: - SpeakerSourceMediaPlayer *parent_; -}; - -} // namespace esphome::speaker_source - -#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index b6653fe5433..4f150e87fff 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -16,8 +16,6 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_SPEAKER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -59,10 +57,6 @@ _PIPELINE_INFO = { ), } -SetPlaylistDelayAction = speaker_source_ns.class_( - "SetPlaylistDelayAction", automation.Action -) - _validate_pipeline = media_player.validate_preferred_format( "speaker_source media_player", CONF_SPEAKER @@ -232,25 +226,11 @@ SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "speaker_source.set_playlist_delay", - SetPlaylistDelayAction, SET_PLAYLIST_DELAY_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_playlist_delay_ms({}, {})", + ((CONF_PIPELINE, cg.uint8), (CONF_DELAY, cg.uint32)), + ), ) -async def set_playlist_delay_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - template_ = await cg.templatable(config[CONF_PIPELINE], args, cg.uint8) - cg.add(var.set_pipeline(template_)) - - template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) - cg.add(var.set_delay(template_)) - - return var diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index a33a1a16509..6a000b8f215 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -196,10 +196,19 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so vTaskDelay(pdMS_TO_TICKS(timeout_ms)); return 0; } + // Reserve frames before the speaker can play them and report their progress, + // then release the portion it did not accept. + const uint32_t reserved_frames = stream_info.bytes_to_frames(length); + ps.pending_frames.fetch_add(reserved_frames, std::memory_order_relaxed); size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms)); - if (bytes_written > 0) { - // Track frames sent to speaker for this source - ps.pending_frames.fetch_add(stream_info.bytes_to_frames(bytes_written), std::memory_order_relaxed); + const uint32_t unused_frames = reserved_frames - stream_info.bytes_to_frames(bytes_written); + if (unused_frames > 0) { + // The main loop may reset pending_frames while play() is blocked. + // Clamp the release so that a concurrent reset cannot cause underflow. + uint32_t pending = ps.pending_frames.load(std::memory_order_relaxed); + while (!ps.pending_frames.compare_exchange_weak(pending, pending - std::min(pending, unused_frames), + std::memory_order_relaxed)) { + } } return bytes_written; } @@ -809,8 +818,11 @@ void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { } void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit within the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); for (auto &ps : this->pipelines_) { if (ps.is_configured()) { @@ -829,15 +841,6 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { } } - // Turn on the mute state if the volume is effectively zero, off otherwise. - // Pass publish=false to avoid saving twice. - if (volume < 0.001f) { - this->set_mute_state_(true, false); - } else { - this->set_mute_state_(false, false); - } - - // Save after mute mutation so the restored state has the correct is_muted_ value if (publish) { this->save_volume_restore_state_(); } diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 14428bed629..ae2d2906edf 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -44,16 +44,8 @@ class SPIDelegateHw : public SPIDelegate { #ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) - // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be - // so we need to copy the data to a temporary buffer - if (reinterpret_cast(ptr) & 0x3) { - ESP_LOGVV(TAG, "SPI write buffer not word aligned, copying to temporary buffer"); - auto txbuf = std::vector(length); - memcpy(txbuf.data(), ptr, length); - this->channel_->writeBytes(txbuf.data(), length); - } else { - this->channel_->writeBytes(ptr, length); - } + // writeBytes() needs a word aligned pointer; transferBytes() bounces unaligned chunks through a stack buffer + this->channel_->transferBytes(ptr, nullptr, length); #else this->channel_->writeBytes(ptr, length); #endif diff --git a/esphome/components/split_buffer/split_buffer.cpp b/esphome/components/split_buffer/split_buffer.cpp index 526a19c71cc..63a0f0d9195 100644 --- a/esphome/components/split_buffer/split_buffer.cpp +++ b/esphome/components/split_buffer/split_buffer.cpp @@ -1,5 +1,8 @@ #include "split_buffer.h" +#include +#include + #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -8,15 +11,14 @@ static constexpr const char *const TAG = "split_buffer"; SplitBuffer::~SplitBuffer() { this->free(); } -bool SplitBuffer::init(size_t total_length) { +bool SplitBuffer::init(size_t total_length, size_t max_buffer_size) { this->free(); // Clean up any existing allocation - if (total_length == 0) { + if (total_length == 0 || max_buffer_size == 0) { return false; } - this->total_length_ = total_length; - size_t current_buffer_size = total_length; + size_t current_buffer_size = std::min(total_length, max_buffer_size); RAMAllocator ptr_allocator; RAMAllocator allocator; @@ -63,6 +65,7 @@ bool SplitBuffer::init(size_t total_length) { this->buffers_ = temp_buffers; this->buffer_count_ = needed_buffers; this->buffer_size_ = current_buffer_size; + this->total_length_ = total_length; ESP_LOGD(TAG, "Allocated %zu * %zu bytes - %zu bytes", this->buffer_count_, this->buffer_size_, this->total_length_); return true; @@ -122,6 +125,34 @@ uint8_t &SplitBuffer::operator[](size_t index) { return const_cast(static_cast(this)->operator[](index)); } +const uint8_t *SplitBuffer::get_span(size_t index, size_t &length) const { + if (index >= this->total_length_) { + length = 0; + return nullptr; + } + const size_t offset = index % this->buffer_size_; + length = std::min(this->buffer_size_ - offset, this->total_length_ - index); + return this->buffers_[index / this->buffer_size_] + offset; +} + +uint8_t *SplitBuffer::get_span(size_t index, size_t &length) { + return const_cast(static_cast(this)->get_span(index, length)); +} + +void SplitBuffer::write(size_t index, const uint8_t *data, size_t length) { + while (length != 0) { + size_t span_length; + uint8_t *span = this->get_span(index, span_length); + if (span == nullptr) + return; + span_length = std::min(span_length, length); + memcpy(span, data, span_length); + index += span_length; + data += span_length; + length -= span_length; + } +} + /** * Fill the entire buffer with a single byte value * @param value Fill value diff --git a/esphome/components/split_buffer/split_buffer.h b/esphome/components/split_buffer/split_buffer.h index b615ddce74d..6f4ab35e78d 100644 --- a/esphome/components/split_buffer/split_buffer.h +++ b/esphome/components/split_buffer/split_buffer.h @@ -16,8 +16,8 @@ class SplitBuffer { SplitBuffer() = default; ~SplitBuffer(); - // Initialize the buffer with the desired total length - bool init(size_t total_length); + // Initialize the buffer with the desired total length; no sub-buffer will be larger than `max_buffer_size` + bool init(size_t total_length, size_t max_buffer_size = SIZE_MAX); // Free all allocated buffers void free(); @@ -27,6 +27,13 @@ class SplitBuffer { const uint8_t &operator[](size_t index) const; void fill(uint8_t value) const; + // Pointer to the byte at `index`; `length` is set to how many bytes are contiguous from there. + // Returns nullptr with `length` 0 if `index` is out of range. + const uint8_t *get_span(size_t index, size_t &length) const; + uint8_t *get_span(size_t index, size_t &length); + // Copy `length` bytes from `data` into the buffer starting at `index`; bytes past the end are dropped. + void write(size_t index, const uint8_t *data, size_t length); + // Get the total length size_t size() const { return this->total_length_; } diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index efa5b0bf150..00144adacd7 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -66,24 +66,10 @@ SprinklerControllerSwitch = sprinkler_ns.class_( "SprinklerControllerSwitch", switch.Switch, cg.Component ) -SetDividerAction = sprinkler_ns.class_("SetDividerAction", automation.Action) -SetMultiplierAction = sprinkler_ns.class_("SetMultiplierAction", automation.Action) -QueueValveAction = sprinkler_ns.class_("QueueValveAction", automation.Action) -ClearQueuedValvesAction = sprinkler_ns.class_( - "ClearQueuedValvesAction", automation.Action -) -SetRepeatAction = sprinkler_ns.class_("SetRepeatAction", automation.Action) -SetRunDurationAction = sprinkler_ns.class_("SetRunDurationAction", automation.Action) -StartFromQueueAction = sprinkler_ns.class_("StartFromQueueAction", automation.Action) -StartFullCycleAction = sprinkler_ns.class_("StartFullCycleAction", automation.Action) StartSingleValveAction = sprinkler_ns.class_( "StartSingleValveAction", automation.Action ) ShutdownAction = sprinkler_ns.class_("ShutdownAction", automation.Action) -NextValveAction = sprinkler_ns.class_("NextValveAction", automation.Action) -PreviousValveAction = sprinkler_ns.class_("PreviousValveAction", automation.Action) -PauseAction = sprinkler_ns.class_("PauseAction", automation.Action) -ResumeAction = sprinkler_ns.class_("ResumeAction", automation.Action) ResumeOrStartAction = sprinkler_ns.class_("ResumeOrStartAction", automation.Action) @@ -418,102 +404,63 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( - "sprinkler.set_divider", - SetDividerAction, - SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, - synchronous=True, -) -async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DIVIDER], args, cg.uint32) - cg.add(var.set_divider(template_)) - return var +_VALVE_AND_DURATION = ((CONF_VALVE_NUMBER, cg.size_t), (CONF_RUN_DURATION, cg.uint32)) - -@automation.register_action( - "sprinkler.set_multiplier", - SetMultiplierAction, - SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, - synchronous=True, -) -async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MULTIPLIER], args, cg.float_) - cg.add(var.set_multiplier(template_)) - return var - - -@automation.register_action( - "sprinkler.queue_valve", - QueueValveAction, - SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, - synchronous=True, -) -async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) - cg.add(var.set_valve_number(template_)) - template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) - cg.add(var.set_valve_run_duration(template_)) - return var - - -@automation.register_action( - "sprinkler.set_repeat", - SetRepeatAction, - SPRINKLER_ACTION_REPEAT_SCHEMA, - synchronous=True, -) -async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.uint32) - cg.add(var.set_repeat(template_)) - return var - - -@automation.register_action( - "sprinkler.set_valve_run_duration", - SetRunDurationAction, - SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, - synchronous=True, -) -async def sprinkler_set_valve_run_duration_to_code( - config, action_id, template_arg, args +for _name, _schema, _apply in ( + ( + "sprinkler.set_divider", + SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, + automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), + ), + ( + "sprinkler.set_multiplier", + SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, + automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), + ), + ( + "sprinkler.queue_valve", + SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, + automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), + ), + ( + "sprinkler.set_repeat", + SPRINKLER_ACTION_REPEAT_SCHEMA, + automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), + ), + ( + "sprinkler.set_valve_run_duration", + SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, + automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), + ), + ( + "sprinkler.start_from_queue", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("start_from_queue()"), + ), + ( + "sprinkler.start_full_cycle", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("start_full_cycle()"), + ), + ( + "sprinkler.clear_queued_valves", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("clear_queued_valves()"), + ), + ( + "sprinkler.next_valve", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("next_valve()"), + ), + ( + "sprinkler.previous_valve", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("previous_valve()"), + ), + ("sprinkler.pause", SPRINKLER_ACTION_SCHEMA, automation.ApplyCall("pause()")), + ("sprinkler.resume", SPRINKLER_ACTION_SCHEMA, automation.ApplyCall("resume()")), ): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) - cg.add(var.set_valve_number(template_)) - template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) - cg.add(var.set_valve_run_duration(template_)) - return var - - -@automation.register_action( - "sprinkler.start_from_queue", - StartFromQueueAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) -async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "sprinkler.start_full_cycle", - StartFullCycleAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) -async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) + automation.register_apply_action(_name, _schema, _apply) @automation.register_action( @@ -533,45 +480,20 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, return var -@automation.register_action( - "sprinkler.clear_queued_valves", - ClearQueuedValvesAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sprinkler.next_valve", - NextValveAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sprinkler.previous_valve", - PreviousValveAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( +automation.register_simple_action( "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.shutdown", ShutdownAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_simple_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) async def to_code(config): diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index beeec96b98a..1de0e1ee11e 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,104 +6,6 @@ namespace esphome::sprinkler { -template class SetDividerAction final : public Action { - public: - explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, divider) - - void play(const Ts &...x) override { this->sprinkler_->set_divider(this->divider_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetMultiplierAction final : public Action { - public: - explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(float, multiplier) - - void play(const Ts &...x) override { this->sprinkler_->set_multiplier(this->multiplier_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class QueueValveAction final : public Action { - public: - explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->queue_valve(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - -template class ClearQueuedValvesAction final : public Action { - public: - explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->clear_queued_valves(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetRepeatAction final : public Action { - public: - explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, repeat) - - void play(const Ts &...x) override { this->sprinkler_->set_repeat(this->repeat_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetRunDurationAction final : public Action { - public: - explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->set_valve_run_duration(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFromQueueAction final : public Action { - public: - explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_from_queue(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFullCycleAction final : public Action { - public: - explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_full_cycle(); } - - protected: - Sprinkler *sprinkler_; -}; - template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -132,46 +34,6 @@ template class ShutdownAction final : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction final : public Action { - public: - explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->next_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PreviousValveAction final : public Action { - public: - explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->previous_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PauseAction final : public Action { - public: - explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->pause(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class ResumeAction final : public Action { - public: - explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->resume(); } - - protected: - Sprinkler *sprinkler_; -}; - template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9fd0d9208bb..cdec1581266 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) { if (this->auto_adv_sw_->state == auto_advance) { return; } - if (auto_advance) { - this->auto_adv_sw_->turn_on(); - } else { - this->auto_adv_sw_->turn_off(); - } + this->auto_adv_sw_->control(auto_advance); } void Sprinkler::set_repeat(optional repeat) { @@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) { if (this->queue_enable_sw_->state == queue_enable) { return; } - if (queue_enable) { - this->queue_enable_sw_->turn_on(); - } else { - this->queue_enable_sw_->turn_off(); - } + this->queue_enable_sw_->control(queue_enable); } void Sprinkler::set_reverse(const bool reverse) { @@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) { if (this->reverse_sw_->state == reverse) { return; } - if (reverse) { - this->reverse_sw_->turn_on(); - } else { - this->reverse_sw_->turn_off(); - } + this->reverse_sw_->control(reverse); } void Sprinkler::set_standby(const bool standby) { @@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) { if (this->standby_sw_->state == standby) { return; } - if (standby) { - this->standby_sw_->turn_on(); - } else { - this->standby_sw_->turn_off(); - } + this->standby_sw_->control(standby); } uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h deleted file mode 100644 index ba978e7770b..00000000000 --- a/esphome/components/sps30/automation.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" -#include "sps30.h" - -namespace esphome::sps30 { - -template class StartFanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } -}; - -template class StartMeasurementAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start_measurement(); } -}; - -template class StopMeasurementAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_measurement(); } -}; - -} // namespace esphome::sps30 diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 681166cd3c6..b8a4e080213 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_MICROMETER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -39,11 +37,6 @@ SPS30Component = sps30_ns.class_( "SPS30Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) -# Actions -StartFanAction = sps30_ns.class_("StartFanAction", automation.Action) -StartMeasurementAction = sps30_ns.class_("StartMeasurementAction", automation.Action) -StopMeasurementAction = sps30_ns.class_("StopMeasurementAction", automation.Action) - CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" CONF_IDLE_INTERVAL = "idle_interval" @@ -128,45 +121,17 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_4_0]) - cg.add(var.set_pm_4_0_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PMC_0_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_0_5]) - cg.add(var.set_pmc_0_5_sensor(sens)) - - if CONF_PMC_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_1_0]) - cg.add(var.set_pmc_1_0_sensor(sens)) - - if CONF_PMC_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_2_5]) - cg.add(var.set_pmc_2_5_sensor(sens)) - - if CONF_PMC_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_4_0]) - cg.add(var.set_pmc_4_0_sensor(sens)) - - if CONF_PMC_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_10_0]) - cg.add(var.set_pmc_10_0_sensor(sens)) - - if CONF_PM_SIZE in config: - sens = await sensor.new_sensor(config[CONF_PM_SIZE]) - cg.add(var.set_pm_size_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_4_0, var.set_pm_4_0_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PMC_0_5, var.set_pmc_0_5_sensor) + await sensors(CONF_PMC_1_0, var.set_pmc_1_0_sensor) + await sensors(CONF_PMC_2_5, var.set_pmc_2_5_sensor) + await sensors(CONF_PMC_4_0, var.set_pmc_4_0_sensor) + await sensors(CONF_PMC_10_0, var.set_pmc_10_0_sensor) + await sensors(CONF_PM_SIZE, var.set_pm_size_sensor) if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) @@ -182,30 +147,11 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "sps30.start_fan_autoclean", - StartFanAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sps30.start_measurement", - StartMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sps30.stop_measurement", - StopMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) -async def sps30_action_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]) - return var +for _name, _call in ( + ("sps30.start_fan_autoclean", "start_fan_cleaning()"), + ("sps30.start_measurement", "start_measurement()"), + ("sps30.stop_measurement", "stop_measurement()"), +): + automation.register_apply_action( + _name, SPS30_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 83f7bc9ce58..47b200c2de6 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 } void ST7701S::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void ST7701S::write_command_(uint8_t value) { diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index b3a60af8c33..2e07e24522c 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -152,8 +152,6 @@ void ST7789V::update() { this->write_display_data(); } -void ST7789V::set_model_str(const char *model_str) { this->model_str_ = model_str; } - void ST7789V::write_display_data() { uint16_t x1 = this->offset_width_; uint16_t x2 = x1 + get_width_internal() - 1; diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 1b7ba318a6c..4011e607c25 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -110,7 +110,7 @@ class ST7789V final : public display::DisplayBuffer, public spi::SPIDevice { public: - void set_model_str(const char *model_str); + void set_model_str(const char *model_str) { this->model_str_ = model_str; } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } void set_backlight_pin(GPIOPin *backlight_pin) { this->backlight_pin_ = backlight_pin; } diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index b5a0586e31d..58d4be97fae 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor, sensor +from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( CONF_BINARY_SENSORS, @@ -14,7 +15,6 @@ AUTO_LOAD = ["socket"] CODEOWNERS = ["@Links2004"] DEPENDENCIES = ["network"] -CONF_HOST = "host" CONF_PREFIX = "prefix" statsd_component_ns = cg.esphome_ns.namespace("statsd") diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 28cf4cd0832..3c25a9e57de 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -7,6 +7,8 @@ namespace esphome::status { class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: + // User provided, not "= default": `new(p) StatusBinarySensor()` would zero-fill .bss that is already zero. + StatusBinarySensor() {} void update() override; void setup() override; diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 8e801876628..017fd95cd89 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv @@ -11,18 +14,13 @@ from esphome.const import ( CONF_TARGET, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import SafeExpType IS_PLATFORM_COMPONENT = True stepper_ns = cg.esphome_ns.namespace("stepper") Stepper = stepper_ns.class_("Stepper") -SetTargetAction = stepper_ns.class_("SetTargetAction", automation.Action) -ReportPositionAction = stepper_ns.class_("ReportPositionAction", automation.Action) -SetSpeedAction = stepper_ns.class_("SetSpeedAction", automation.Action) -SetAccelerationAction = stepper_ns.class_("SetAccelerationAction", automation.Action) -SetDecelerationAction = stepper_ns.class_("SetDecelerationAction", automation.Action) - def validate_acceleration(value): value = cv.string(value) @@ -90,99 +88,53 @@ async def register_stepper(var, config): await setup_stepper_core_(var, config) -@automation.register_action( - "stepper.set_target", - SetTargetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_TARGET): cv.templatable(cv.int_), - } - ), - synchronous=True, +def _register_stepper_action( + name: str, + key: str, + validator: Callable[[Any], Any], + target: str, + type_: SafeExpType, + *extra: automation.ApplyCall, +) -> None: + automation.register_apply_action( + f"stepper.{name}", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Stepper), + cv.Required(key): cv.templatable(validator), + } + ), + automation.ApplyField(key, target, type_), + *extra, + ) + + +_register_stepper_action("set_target", CONF_TARGET, cv.int_, "set_target", cg.int32) +_register_stepper_action( + "report_position", CONF_POSITION, cv.int_, "report_position", cg.int32 ) -async def stepper_set_target_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TARGET], args, cg.int32) - cg.add(var.set_target(template_)) - return var - - -@automation.register_action( - "stepper.report_position", - ReportPositionAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_POSITION): cv.templatable(cv.int_), - } - ), - synchronous=True, +_register_stepper_action( + "set_speed", + CONF_SPEED, + validate_speed, + "set_max_speed", + cg.float_, + automation.ApplyCall("on_update_speed()"), ) -async def stepper_report_position_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_POSITION], args, cg.int32) - cg.add(var.set_position(template_)) - return var - - -@automation.register_action( - "stepper.set_speed", - SetSpeedAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_SPEED): cv.templatable(validate_speed), - } - ), - synchronous=True, +_register_stepper_action( + "set_acceleration", + CONF_ACCELERATION, + validate_acceleration, + "set_acceleration", + cg.float_, ) -async def stepper_set_speed_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SPEED], args, cg.float_) - cg.add(var.set_speed(template_)) - return var - - -@automation.register_action( - "stepper.set_acceleration", - SetAccelerationAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_ACCELERATION): cv.templatable(validate_acceleration), - } - ), - synchronous=True, +_register_stepper_action( + "set_deceleration", + CONF_DECELERATION, + validate_acceleration, + "set_deceleration", + cg.float_, ) -async def stepper_set_acceleration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_ACCELERATION], args, cg.float_) - cg.add(var.set_acceleration(template_)) - return var - - -@automation.register_action( - "stepper.set_deceleration", - SetDecelerationAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_DECELERATION): cv.templatable(validate_acceleration), - } - ), - synchronous=True, -) -async def stepper_set_deceleration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DECELERATION], args, cg.float_) - cg.add(var.set_deceleration(template_)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 06ef3bab375..8a8fbcd896c 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" namespace esphome::stepper { @@ -37,74 +36,4 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction final : public Action { - public: - explicit SetTargetAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(int32_t, target) - - void play(const Ts &...x) override { this->parent_->set_target(this->target_.value(x...)); } - - protected: - Stepper *parent_; -}; - -template class ReportPositionAction final : public Action { - public: - explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(int32_t, position) - - void play(const Ts &...x) override { this->parent_->report_position(this->position_.value(x...)); } - - protected: - Stepper *parent_; -}; - -template class SetSpeedAction final : public Action { - public: - explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, speed); - - void play(const Ts &...x) override { - float speed = this->speed_.value(x...); - this->parent_->set_max_speed(speed); - this->parent_->on_update_speed(); - } - - protected: - Stepper *parent_; -}; - -template class SetAccelerationAction final : public Action { - public: - explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, acceleration); - - void play(const Ts &...x) override { - float acceleration = this->acceleration_.value(x...); - this->parent_->set_acceleration(acceleration); - } - - protected: - Stepper *parent_; -}; - -template class SetDecelerationAction final : public Action { - public: - explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, deceleration); - - void play(const Ts &...x) override { - float deceleration = this->deceleration_.value(x...); - this->parent_->set_deceleration(deceleration); - } - - protected: - Stepper *parent_; -}; - } // namespace esphome::stepper diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 33a5c677bdf..73ddefe9729 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -13,8 +13,6 @@ from esphome.const import ( CONF_TIME_ID, CONF_TRIGGER_ID, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] @@ -24,7 +22,6 @@ Sun = sun_ns.class_("Sun") SunTrigger = sun_ns.class_( "SunTrigger", cg.PollingComponent, automation.Trigger.template() ) -SunCondition = sun_ns.class_("SunCondition", automation.Condition) CONF_SUN_ID = "sun_id" CONF_ELEVATION = "elevation" @@ -142,53 +139,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) -@automation.register_condition( +SUN_HORIZON_CONDITION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Sun), + cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( + elevation + ), + } +) + +automation.register_apply_condition( "sun.is_above_horizon", - SunCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(Sun), - cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( - elevation - ), - } - ), + SUN_HORIZON_CONDITION_SCHEMA, + automation.ApplyCall("elevation() > {}", ((CONF_ELEVATION, cg.double),)), ) -async def sun_above_horizon_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) - cg.add(var.set_elevation(templ)) - cg.add(var.set_above(True)) - return var - -@automation.register_condition( +automation.register_apply_condition( "sun.is_below_horizon", - SunCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(Sun), - cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( - elevation - ), - } - ), + SUN_HORIZON_CONDITION_SCHEMA, + automation.ApplyCall("elevation() < {}", ((CONF_ELEVATION, cg.double),)), ) -async def sun_below_horizon_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) - cg.add(var.set_elevation(templ)) - cg.add(var.set_above(False)) - return var diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index ea9e05042d9..af2af15dd29 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -109,23 +109,4 @@ class SunTrigger final : public Trigger<>, public PollingComponent, public Paren double elevation_; }; -template class SunCondition final : public Condition, public Parented { - public: - TEMPLATABLE_VALUE(double, elevation); - void set_above(bool above) { above_ = above; } - - bool check(const Ts &...x) override { - double elevation = this->elevation_.value(x...); - double current = this->parent_->elevation(); - if (this->above_) { - return current > elevation; - } else { - return current < elevation; - } - } - - protected: - bool above_; -}; - } // namespace esphome::sun diff --git a/esphome/components/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index 26435cfa67d..2959f365187 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -76,21 +76,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) - if ac_voltage_config := config.get(CONF_AC_VOLTAGE): - sens = await sensor.new_sensor(ac_voltage_config) - cg.add(hub.set_ac_voltage(sens)) - if dc_voltage_config := config.get(CONF_DC_VOLTAGE): - sens = await sensor.new_sensor(dc_voltage_config) - cg.add(hub.set_dc_voltage(sens)) - if ac_power_config := config.get(CONF_AC_POWER): - sens = await sensor.new_sensor(ac_power_config) - cg.add(hub.set_ac_power(sens)) - if dc_power_config := config.get(CONF_DC_POWER): - sens = await sensor.new_sensor(dc_power_config) - cg.add(hub.set_dc_power(sens)) - if limiter_power_config := config.get(CONF_LIMITER_POWER): - sens = await sensor.new_sensor(limiter_power_config) - cg.add(hub.set_limiter_power(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(hub.set_temperature(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_VOLTAGE, hub.set_ac_voltage) + await sensors(CONF_DC_VOLTAGE, hub.set_dc_voltage) + await sensors(CONF_AC_POWER, hub.set_ac_power) + await sensors(CONF_DC_POWER, hub.set_dc_power) + await sensors(CONF_LIMITER_POWER, hub.set_limiter_power) + await sensors(CONF_TEMPERATURE, hub.set_temperature) diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index eae69fb4df2..c0a60cacbf3 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -25,9 +25,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) - if state_config := config.get(CONF_STATE): - sens = await text_sensor.new_text_sensor(state_config) - cg.add(hub.set_state(sens)) - if serial_number_config := config.get(CONF_SERIAL_NUMBER): - sens = await text_sensor.new_text_sensor(serial_number_config) - cg.add(hub.set_serial_number(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_STATE, hub.set_state) + await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 18b95113cc0..c34a73a36d1 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee import esphome.config_validation as cv @@ -20,14 +20,16 @@ from esphome.const import ( DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -54,13 +56,6 @@ RESTORE_MODES = { } -ControlAction = switch_ns.class_("ControlAction", automation.Action) -ToggleAction = switch_ns.class_("ToggleAction", automation.Action) -TurnOffAction = switch_ns.class_("TurnOffAction", automation.Action) -TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) -SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) - -SwitchCondition = switch_ns.class_("SwitchCondition", Condition) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -180,6 +175,13 @@ async def new_switch(config, *args): return var +def sub_switches( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_switch.""" + return SubEntities(new_switch, config, parent) + + SWITCH_ACTION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Switch), @@ -193,41 +195,24 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "switch.control", + SWITCH_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "control", cg.bool_), ) -async def switch_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var +for _name, _call in ( + ("switch.toggle", "toggle()"), + ("switch.turn_off", "turn_off()"), + ("switch.turn_on", "turn_on()"), +): + automation.register_apply_action( + _name, SWITCH_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( - "switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA, synchronous=True +automation.register_apply_condition("switch.is_on", SWITCH_ACTION_SCHEMA, "state") +automation.register_apply_condition( + "switch.is_off", SWITCH_ACTION_SCHEMA, "state == false" ) -@automation.register_action( - "switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -async def switch_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_condition("switch.is_on", SwitchCondition, SWITCH_ACTION_SCHEMA) -async def switch_is_on_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition("switch.is_off", SwitchCondition, SWITCH_ACTION_SCHEMA) -async def switch_is_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h deleted file mode 100644 index 158fb08baff..00000000000 --- a/esphome/components/switch/automation.h +++ /dev/null @@ -1,106 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/switch/switch.h" - -namespace esphome::switch_ { - -template class TurnOnAction final : public Action { - public: - explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->turn_on(); } - - protected: - Switch *switch_; -}; - -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->turn_off(); } - - protected: - Switch *switch_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->toggle(); } - - protected: - Switch *switch_; -}; - -template class ControlAction final : public Action { - public: - explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} - - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto state = this->state_.optional_value(x...); - if (state.has_value()) { - this->switch_->control(*state); - } - } - - protected: - Switch *switch_; -}; - -template class SwitchCondition final : public Condition { - public: - SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - Switch *parent_; - bool state_; -}; - -class SwitchStateTrigger final : public Trigger { - public: - SwitchStateTrigger(Switch *a_switch) { - a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); - } -}; - -class SwitchTurnOnTrigger final : public Trigger<> { - public: - SwitchTurnOnTrigger(Switch *a_switch) { - a_switch->add_on_state_callback([this](bool state) { - if (state) { - this->trigger(); - } - }); - } -}; - -class SwitchTurnOffTrigger final : public Trigger<> { - public: - SwitchTurnOffTrigger(Switch *a_switch) { - a_switch->add_on_state_callback([this](bool state) { - if (!state) { - this->trigger(); - } - }); - } -}; - -template class SwitchPublishAction final : public Action { - public: - SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { this->switch_->publish_state(this->state_.value(x...)); } - - protected: - Switch *switch_; -}; - -} // namespace esphome::switch_ diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 8413c7b4936..2cc8ad782d9 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -10,7 +10,6 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} void Switch::control(bool target_state) { - ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); if (target_state) { this->turn_on(); } else { @@ -58,6 +57,7 @@ void Switch::publish_state(bool state) { if (!this->publish_dedup_.next(state)) return; this->state = state != this->inverted_; + this->set_has_state(true); if (restore_mode & RESTORE_MODE_PERSISTENT_MASK) this->rtc_.save(&this->state); diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 29e3ad5359f..ce47a570501 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -131,24 +131,9 @@ SHAPING = { "NONE": SX126xPulseShape.NO_FILTER, } -RunImageCalAction = sx126x_ns.class_( - "RunImageCalAction", automation.Action, cg.Parented.template(SX126x) -) SendPacketAction = sx126x_ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(SX126x) ) -SetModeTxAction = sx126x_ns.class_( - "SetModeTxAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeRxAction = sx126x_ns.class_( - "SetModeRxAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeSleepAction = sx126x_ns.class_( - "SetModeSleepAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeStandbyAction = sx126x_ns.class_( - "SetModeStandbyAction", automation.Action, cg.Parented.template(SX126x) -) def validate_raw_data(value: Any) -> bytes | list[int]: @@ -302,40 +287,15 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "sx126x.run_image_cal", - RunImageCalAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx126x.set_mode_tx", - SetModeTxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx126x.set_mode_rx", - SetModeRxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx126x.set_mode_standby", - SetModeStandbyAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def no_args_action_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]) - return var - +for _name, _call in ( + ("sx126x.run_image_cal", "run_image_cal()"), + ("sx126x.set_mode_tx", "set_mode_tx()"), + ("sx126x.set_mode_rx", "set_mode_rx()"), + ("sx126x.set_mode_standby", "set_mode_standby(sx126x::STDBY_XOSC)"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( { @@ -344,24 +304,11 @@ SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( } ) - -@automation.register_action( +automation.register_apply_action( "sx126x.set_mode_sleep", - SetModeSleepAction, SET_MODE_SLEEP_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_COLD, "set_mode_sleep", cg.bool_), ) -async def set_mode_sleep_action_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]) - template_ = await cg.templatable(config[CONF_COLD], args, bool) - cg.add(var.set_cold(template_)) - return var SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 4eb33abaa1e..411de123413 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,11 +6,6 @@ namespace esphome::sx126x { -template class RunImageCalAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->run_image_cal(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { @@ -43,25 +38,4 @@ template class SendPacketAction final : public Action, pu } data_; }; -template class SetModeTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_tx(); } -}; - -template class SetModeRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_rx(); } -}; - -template class SetModeSleepAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, cold) - void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } -}; - -template class SetModeStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } -}; - } // namespace esphome::sx126x diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 34f2d4122f6..9f95c618445 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -120,24 +120,9 @@ SHAPING = { "NONE": SX127xPaRamp.SHAPING_NONE, } -RunImageCalAction = sx127x_ns.class_( - "RunImageCalAction", automation.Action, cg.Parented.template(SX127x) -) SendPacketAction = sx127x_ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(SX127x) ) -SetModeTxAction = sx127x_ns.class_( - "SetModeTxAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeRxAction = sx127x_ns.class_( - "SetModeRxAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeSleepAction = sx127x_ns.class_( - "SetModeSleepAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeStandbyAction = sx127x_ns.class_( - "SetModeStandbyAction", automation.Action, cg.Parented.template(SX127x) -) def validate_raw_data(value: Any) -> bytes | list[int]: @@ -286,45 +271,16 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "sx127x.run_image_cal", - RunImageCalAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx127x.set_mode_tx", - SetModeTxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx127x.set_mode_rx", - SetModeRxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx127x.set_mode_sleep", - SetModeSleepAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sx127x.set_mode_standby", - SetModeStandbyAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) -async def no_args_action_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]) - return var +for _name, _call in ( + ("sx127x.run_image_cal", "run_image_cal()"), + ("sx127x.set_mode_tx", "set_mode_tx()"), + ("sx127x.set_mode_rx", "set_mode_rx()"), + ("sx127x.set_mode_sleep", "set_mode_sleep()"), + ("sx127x.set_mode_standby", "set_mode_standby()"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index f6a4537e231..2ed16146813 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,11 +6,6 @@ namespace esphome::sx127x { -template class RunImageCalAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->run_image_cal(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { @@ -43,24 +38,4 @@ template class SendPacketAction final : public Action, pu } data_; }; -template class SetModeTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_tx(); } -}; - -template class SetModeRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_rx(); } -}; - -template class SetModeSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } -}; - -template class SetModeStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_standby(); } -}; - } // namespace esphome::sx127x diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index c95850aadc1..a46d7344b68 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -42,16 +42,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if vbus_connected_config := config.get(CONF_VBUS_CONNECTED): - sens = await binary_sensor.new_binary_sensor(vbus_connected_config) - cg.add(parent.add_listener(sens)) - - if charging_config := config.get(CONF_CHARGING): - sens = await binary_sensor.new_binary_sensor(charging_config) - cg.add(parent.add_listener(sens)) - - if charge_done_config := config.get(CONF_CHARGE_DONE): - sens = await binary_sensor.new_binary_sensor(charge_done_config) - cg.add(parent.add_listener(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_VBUS_CONNECTED, hub.add_listener) + await binary_sensors(CONF_CHARGING, hub.add_listener) + await binary_sensors(CONF_CHARGE_DONE, hub.add_listener) diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index 8f8090b6ee8..16db2aa96b3 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -73,24 +73,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if vbus_voltage_config := config.get(CONF_VBUS_VOLTAGE): - sens = await sensor.new_sensor(vbus_voltage_config) - cg.add(parent.add_listener(sens)) - - if battery_voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(battery_voltage_config) - cg.add(parent.add_listener(sens)) - - if system_voltage_config := config.get(CONF_SYSTEM_VOLTAGE): - sens = await sensor.new_sensor(system_voltage_config) - cg.add(parent.add_listener(sens)) - - if charge_current_config := config.get(CONF_CHARGE_CURRENT): - sens = await sensor.new_sensor(charge_current_config) - cg.add(parent.add_listener(sens)) - - if precharge_current_config := config.get(CONF_PRECHARGE_CURRENT): - sens = await sensor.new_sensor(precharge_current_config) - cg.add(parent.add_listener(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VBUS_VOLTAGE, hub.add_listener) + await sensors(CONF_BATTERY_VOLTAGE, hub.add_listener) + await sensors(CONF_SYSTEM_VOLTAGE, hub.add_listener) + await sensors(CONF_CHARGE_CURRENT, hub.add_listener) + await sensors(CONF_PRECHARGE_CURRENT, hub.add_listener) diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index 03a55393b9f..db756960e1b 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -38,16 +38,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if bus_status_config := config.get(CONF_BUS_STATUS): - sens = await text_sensor.new_text_sensor(bus_status_config) - cg.add(parent.add_listener(sens)) - - if charge_status_config := config.get(CONF_CHARGE_STATUS): - sens = await text_sensor.new_text_sensor(charge_status_config) - cg.add(parent.add_listener(sens)) - - if ntc_status_config := config.get(CONF_NTC_STATUS): - sens = await text_sensor.new_text_sensor(ntc_status_config) - cg.add(parent.add_listener(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_BUS_STATUS, hub.add_listener) + await text_sensors(CONF_CHARGE_STATUS, hub.add_listener) + await text_sensors(CONF_NTC_STATUS, hub.add_listener) diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index bf7fe9a3072..e25567c783d 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -4,7 +4,7 @@ from esphome.components.logger import LOG_LEVELS, is_log_level, request_log_list from esphome.components.time import RealTimeClock from esphome.components.udp import CONF_UDP_ID import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID +from esphome.const import CONF_FORMAT, CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID from esphome.cpp_types import Component, Parented from esphome.types import ConfigType @@ -14,6 +14,12 @@ DEPENDENCIES = ["udp", "logger", "time"] syslog_ns = cg.esphome_ns.namespace("syslog") Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent)) +SyslogFormat = syslog_ns.enum("SyslogFormat") + +SYSLOG_FORMATS = { + "RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164, + "RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424, +} CONF_STRIP = "strip" CONF_FACILITY = "facility" @@ -25,6 +31,9 @@ CONFIG_SCHEMA = udp.UDP_SCHEMA.extend( cv.Optional(CONF_LEVEL, default="DEBUG"): is_log_level, cv.Optional(CONF_STRIP, default=True): cv.boolean, cv.Optional(CONF_FACILITY, default=16): cv.int_range(0, 23), + cv.Optional(CONF_FORMAT, default="RFC3164"): cv.enum( + SYSLOG_FORMATS, upper=True + ), } ) @@ -41,3 +50,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, parent) cg.add(var.set_strip(config[CONF_STRIP])) cg.add(var.set_facility(config[CONF_FACILITY])) + cg.add(var.set_format(config[CONF_FORMAT])) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 790d08ffa6d..a40829f3153 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -49,27 +49,37 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP) char packet[508]; size_t offset = 0; - size_t remaining = sizeof(packet); - // Write PRI - abort if this fails as packet would be malformed offset = buf_append_printf(packet, sizeof(packet), 0, "<%d>", pri); if (offset == 0) { return; // PRI always produces at least "<0>" (3 chars), so 0 means error } - remaining -= offset; - - // Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails) auto now = this->time_->now(); - size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0; - if (ts_written > 0) { - offset += ts_written; - } else if (remaining > 0) { - packet[offset++] = '-'; - } + if (this->format_ == SYSLOG_FORMAT_RFC5424) { + offset = buf_append_str(packet, sizeof(packet), offset, "1 "); - // Write hostname, tag, and message - offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, - message); + char timestamp[32]; + size_t timestamp_len = now.is_valid() ? now.strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S%z") : 0; + if (timestamp_len == 24) { + // ESPTime formats the numeric offset as +HHMM. RFC 3339 requires +HH:MM. + timestamp[25] = '\0'; + timestamp[24] = timestamp[23]; + timestamp[23] = timestamp[22]; + timestamp[22] = ':'; + offset = buf_append_printf(packet, sizeof(packet), offset, "%s", timestamp); + } else { + offset = buf_append_str(packet, sizeof(packet), offset, "-"); + } + offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s - - - %.*s", App.get_name().c_str(), tag, + (int) len, message); + } else { + // RFC 3164 has no NILVALUE. If the clock is invalid, omit TIMESTAMP so a relay can add it. + if (now.is_valid()) { + offset += now.strftime(packet + offset, sizeof(packet) - offset, "%b %e %H:%M:%S "); + } + offset = buf_append_printf(packet, sizeof(packet), offset, "%s %s: %.*s", App.get_name().c_str(), tag, (int) len, + message); + } // Clamp to exclude null terminator position if buffer was filled if (offset >= sizeof(packet)) { offset = sizeof(packet) - 1; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index 4a76f9ac627..f4b2a7dd62f 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,6 +7,11 @@ #ifdef USE_NETWORK namespace esphome::syslog { +enum SyslogFormat : uint8_t { + SYSLOG_FORMAT_RFC3164, + SYSLOG_FORMAT_RFC5424, +}; + class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} @@ -14,6 +19,7 @@ class Syslog final : public Component, public Parented { void on_log(uint8_t level, const char *tag, const char *message, size_t message_len); void set_strip(bool strip) { this->strip_ = strip; } void set_facility(int facility) { this->facility_ = facility; } + void set_format(SyslogFormat format) { this->format_ = format; } protected: int log_level_; @@ -21,6 +27,7 @@ class Syslog final : public Component, public Parented { time::RealTimeClock *time_; bool strip_{true}; int facility_{16}; + SyslogFormat format_{SYSLOG_FORMAT_RFC3164}; }; } // namespace esphome::syslog #endif diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py new file mode 100644 index 00000000000..0695b43d416 --- /dev/null +++ b/esphome/components/systa_bus/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@Mat931"] + +DEPENDENCIES = ["uart"] + +MULTI_CONF = True + +systa_bus_ns = cg.esphome_ns.namespace("systa_bus") +SystaBus = systa_bus_ns.class_("SystaBus", cg.Component, uart.UARTDevice) + +CONF_SYSTA_BUS_ID = "systa_bus_id" + +CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaBus), + } +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "systa_bus", baud_rate=9600, require_rx=True +) + +_request_listener_slot = cg.slot_counter("SYSTA_BUS_LISTENER_COUNT") + + +async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: + """Register a listener with its bus and count it for the compile-time listener storage.""" + _request_listener_slot(str(systa_bus)) + cg.add(systa_bus.register_listener(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py new file mode 100644 index 00000000000..3def5fba12f --- /dev/null +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -0,0 +1,81 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MODEL, + DEVICE_CLASS_TEMPERATURE, + ICON_PERCENT, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from .. import CONF_SYSTA_BUS_ID, SystaBus, register_systa_bus_listener, systa_bus_ns + +SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) + +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" +CONF_TEMPERATURE_TSA = "temperature_tsa" +CONF_TEMPERATURE_TSE = "temperature_tse" +CONF_TEMPERATURE_TWU = "temperature_twu" +CONF_TEMPERATURE_TW2 = "temperature_tw2" +CONF_PUMP_SPEED = "pump_speed" + +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_PUMP_SPEED_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +MODEL_SENSORS = { + CONF_SYSTASOLAR_AQUA: { + CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TSE: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TWU: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TW2: _TEMPERATURE_SCHEMA, + CONF_PUMP_SPEED: _PUMP_SPEED_SCHEMA, + }, +} + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + **{ + cv.Optional(key): schema + for key, schema in MODEL_SENSORS[CONF_SYSTASOLAR_AQUA].items() + }, + } + ), + }, + key=CONF_MODEL, + lower=True, + space="_", +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + for key in MODEL_SENSORS[config[CONF_MODEL]]: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) + + systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) + await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp new file mode 100644 index 00000000000..08a46771805 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -0,0 +1,37 @@ +#include "systa_bus_sensor.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +static const char *const TAG = "systa_bus.sensor"; + +static int16_t get_i16be(std::span message, size_t start) { + return static_cast(encode_uint16(message[start], message[start + 1])); +} + +void SystaSolarAquaSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_SENSOR(" ", "Temperature TSA", this->temperature_tsa_sensor_); + LOG_SENSOR(" ", "Temperature TSE", this->temperature_tse_sensor_); + LOG_SENSOR(" ", "Temperature TWU", this->temperature_twu_sensor_); + LOG_SENSOR(" ", "Temperature TW2", this->temperature_tw2_sensor_); + LOG_SENSOR(" ", "Pump Speed", this->pump_speed_sensor_); +} + +void SystaSolarAquaSensor::handle_message(std::span message) { + if (get_message_type(message) != MESSAGE_TYPE_AQUA_SENSOR_DATA) + return; + if (this->temperature_tsa_sensor_ != nullptr) + this->temperature_tsa_sensor_->publish_state(get_i16be(message, 4) * 0.1f); + if (this->temperature_tse_sensor_ != nullptr) + this->temperature_tse_sensor_->publish_state(get_i16be(message, 6) * 0.1f); + if (this->temperature_twu_sensor_ != nullptr) + this->temperature_twu_sensor_->publish_state(get_i16be(message, 8) * 0.1f); + if (this->temperature_tw2_sensor_ != nullptr) + this->temperature_tw2_sensor_->publish_state(get_i16be(message, 10) * 0.1f); + if (this->pump_speed_sensor_ != nullptr) + this->pump_speed_sensor_->publish_state(message[12]); +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h new file mode 100644 index 00000000000..3688176d006 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -0,0 +1,28 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } + void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } + void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } + void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } + void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + sensor::Sensor *temperature_tsa_sensor_{nullptr}; + sensor::Sensor *temperature_tse_sensor_{nullptr}; + sensor::Sensor *temperature_twu_sensor_{nullptr}; + sensor::Sensor *temperature_tw2_sensor_{nullptr}; + sensor::Sensor *pump_speed_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp new file mode 100644 index 00000000000..3a2f6717e9b --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -0,0 +1,70 @@ +#include "systa_bus.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::systa_bus { + +static const char *const TAG = "systa_bus"; + +void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } + +static bool checksum(std::span data) { + uint8_t csum = 0; + for (uint8_t i : data) + csum += i; + return csum == 0; +} + +void SystaBus::loop() { + uint8_t c; + while (this->available() && this->read_byte(&c)) + this->handle_byte_(c); +} + +void SystaBus::handle_byte_(uint8_t c) { + if (this->buffer_.empty()) { + if (c == START_BYTE) + this->buffer_.push_back(c); + return; + } + this->buffer_.push_back(c); + if (this->buffer_.size() == 2) { + // The length byte is only trusted for known message types; anything else restarts the search + uint16_t message_type = get_message_type(this->buffer_); + if (message_type != MESSAGE_TYPE_AQUA_SENSOR_DATA) { + ESP_LOGV(TAG, "Unknown message type 0x%04x", message_type); + this->buffer_.clear(); + // A stray start byte followed by a real frame: keep this byte as the new start + if (c == START_BYTE) + this->buffer_.push_back(c); + } + return; + } + if (this->buffer_.size() < this->buffer_[1] + FRAME_OVERHEAD) + return; + if (!checksum(this->buffer_)) { + ESP_LOGW(TAG, "Checksum failed"); + this->resync_(); + return; + } +#ifdef SYSTA_BUS_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->handle_message(this->buffer_); +#endif + this->buffer_.clear(); +} + +// A frame that lost a byte swallows the start of the next one, so the failed bytes are fed back through the +// parser from the second byte on. They are fewer than a full frame, so this cannot fail the checksum again. +void SystaBus::resync_() { + std::array failed; + const size_t count = this->buffer_.size(); + std::copy(this->buffer_.begin(), this->buffer_.end(), failed.begin()); + this->buffer_.clear(); + for (size_t i = 1; i < count; i++) + this->handle_byte_(failed[i]); +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/systa_bus.h b/esphome/components/systa_bus/systa_bus.h new file mode 100644 index 00000000000..99ca31590f5 --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::systa_bus { + +// A frame is a start byte, the payload length, the payload and an 8-bit checksum that makes all bytes sum to +// zero. The first two bytes together are the message type, so the type also fixes the frame length. +static constexpr uint8_t START_BYTE = 0xfc; +static constexpr uint8_t FRAME_OVERHEAD = 3; // start byte, length, checksum + +// SystaSolar Aqua sensor data, 25 bytes: [0..1] type, [2..3] not decoded, big-endian int16 in 0.1 degrees at +// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..23] not decoded. +static constexpr uint16_t MESSAGE_TYPE_AQUA_SENSOR_DATA = 0xfc16; + +// The bus only buffers known message types, so the buffer is sized by the longest of them +static constexpr size_t MAX_MESSAGE_SIZE = (MESSAGE_TYPE_AQUA_SENSOR_DATA & 0xff) + FRAME_OVERHEAD; + +inline uint16_t get_message_type(std::span message) { return encode_uint16(message[0], message[1]); } + +class SystaBusListener { + public: + // Called with every frame that passed the checksum; listeners pick their message type + virtual void handle_message(std::span message) = 0; +}; + +class SystaBus : public uart::UARTDevice, public Component { + public: + void dump_config() override; + void loop() override; + +#ifdef SYSTA_BUS_LISTENER_COUNT + void register_listener(SystaBusListener *listener) { this->listeners_.push_back(listener); } +#endif + + protected: + void handle_byte_(uint8_t c); + void resync_(); + +#ifdef SYSTA_BUS_LISTENER_COUNT + StaticVector listeners_; +#endif + StaticVector buffer_; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/components/tas2780/__init__.py b/esphome/components/tas2780/__init__.py new file mode 100644 index 00000000000..81e00ca74ba --- /dev/null +++ b/esphome/components/tas2780/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@remcom"] diff --git a/esphome/components/tas2780/audio_dac.py b/esphome/components/tas2780/audio_dac.py new file mode 100644 index 00000000000..9fbee8cc297 --- /dev/null +++ b/esphome/components/tas2780/audio_dac.py @@ -0,0 +1,108 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID, CONF_POWER_MODE +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +tas2780_ns = cg.esphome_ns.namespace("tas2780") +TAS2780 = tas2780_ns.class_("TAS2780", AudioDac, cg.PollingComponent, i2c.I2CDevice) +ChannelSelect = tas2780_ns.enum("ChannelSelect") + +CHANNELS = { + "mono": ChannelSelect.MONO_DWN_MIX, + "left": ChannelSelect.LEFT_CHANNEL, + "right": ChannelSelect.RIGHT_CHANNEL, +} + +CONF_VOL_RANGE_MIN = "vol_range_min" +CONF_VOL_RANGE_MAX = "vol_range_max" +CONF_AMP_LEVEL = "amp_level" + +_AMP_LEVEL = cv.int_range(min=0, max=20) +_POWER_MODE = cv.int_range(min=0, max=3) + + +def _validate_vol_range(config: ConfigType) -> ConfigType: + if config[CONF_VOL_RANGE_MIN] >= config[CONF_VOL_RANGE_MAX]: + raise cv.Invalid(f"{CONF_VOL_RANGE_MIN} must be less than {CONF_VOL_RANGE_MAX}") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TAS2780), + cv.Optional(CONF_AMP_LEVEL, default=8): _AMP_LEVEL, + cv.Optional(CONF_POWER_MODE, default=2): _POWER_MODE, + cv.Optional(CONF_VOL_RANGE_MIN, default=0.3): cv.percentage, + cv.Optional(CONF_VOL_RANGE_MAX, default=1.0): cv.percentage, + cv.Optional(CONF_CHANNEL, default="mono"): cv.enum(CHANNELS), + } + ) + .extend(cv.polling_component_schema("5s")) + .extend(i2c.i2c_device_schema(0x38)), + _validate_vol_range, +) + + +TAS2780_ACTION_SCHEMA = maybe_simple_id({cv.GenerateID(): cv.use_id(TAS2780)}) + +for _name, _call in ( + ("tas2780.deactivate", "deactivate()"), + ("tas2780.reset", "reset()"), +): + automation.register_apply_action( + _name, TAS2780_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + +# Without power_mode the configured mode stays; activate() re-initializes only on a change. +automation.register_apply_action( + "tas2780.activate", + maybe_simple_id( + { + cv.GenerateID(): cv.use_id(TAS2780), + cv.Optional(CONF_POWER_MODE): cv.templatable(_POWER_MODE), + } + ), + automation.ApplyField(CONF_POWER_MODE, "set_power_mode", cg.uint8), + automation.ApplyCall("activate()"), +) + +automation.register_apply_action( + "tas2780.update_config", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TAS2780), + cv.Optional(CONF_VOL_RANGE_MIN): cv.templatable(cv.percentage), + cv.Optional(CONF_VOL_RANGE_MAX): cv.templatable(cv.percentage), + cv.Optional(CONF_AMP_LEVEL): cv.templatable(_AMP_LEVEL), + cv.Optional(CONF_CHANNEL): cv.templatable(cv.enum(CHANNELS)), + } + ).add_extra( + cv.has_at_least_one_key( + CONF_VOL_RANGE_MIN, CONF_VOL_RANGE_MAX, CONF_AMP_LEVEL, CONF_CHANNEL + ) + ), + automation.ApplyField(CONF_AMP_LEVEL, "set_amp_level", cg.uint8), + automation.ApplyField(CONF_VOL_RANGE_MIN, "set_vol_range_min", cg.float_), + automation.ApplyField(CONF_VOL_RANGE_MAX, "set_vol_range_max", cg.float_), + automation.ApplyField(CONF_CHANNEL, "set_selected_channel", ChannelSelect), + automation.ApplyCall("apply_config()"), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_amp_level(config[CONF_AMP_LEVEL])) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + cg.add(var.set_vol_range_min(config[CONF_VOL_RANGE_MIN])) + cg.add(var.set_vol_range_max(config[CONF_VOL_RANGE_MAX])) + cg.add(var.set_selected_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/tas2780/tas2780.cpp b/esphome/components/tas2780/tas2780.cpp new file mode 100644 index 00000000000..191dbf823c3 --- /dev/null +++ b/esphome/components/tas2780/tas2780.cpp @@ -0,0 +1,406 @@ +#include "tas2780.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::tas2780 { + +static const char *const TAG = "tas2780"; + +static constexpr uint8_t TAS2780_PAGE_SELECT = 0x00; // Page Select +static constexpr uint8_t TAS2780_PAGE_0 = 0x00; // Page 0 +static constexpr uint8_t TAS2780_PAGE_1 = 0x01; // Page 1 +static constexpr uint8_t TAS2780_PAGE_FD = 0xFD; // Page 0xFD + +/* PAGE 0 */ +static constexpr uint8_t TAS2780_SW_RESET = 0x01; // Software Reset +static constexpr uint8_t TAS2780_SW_RESET_CMD = 0x01; // Trigger software reset +static constexpr uint8_t TAS2780_MODE_CTRL = 0x02; // Device operational mode +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_MASK = 0x07; +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_ACTIVE = 0x00; +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_ACTIVE_MUTED = 0x01; +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_SFTW_SHTDWN = 0x02; + +static constexpr uint8_t TAS2780_CHNL_0 = 0x03; // Y Bridge and Channel settings +static constexpr uint8_t TAS2780_CHNL_0_CDS_MODE_SHIFT = 6; +static constexpr uint8_t TAS2780_CHNL_0_CDS_MODE_MASK = (0x03 << TAS2780_CHNL_0_CDS_MODE_SHIFT); +static constexpr uint8_t TAS2780_CHNL_0_AMP_LEVEL_SHIFT = 1; +static constexpr uint8_t TAS2780_CHNL_0_AMP_LEVEL_MASK = (0x1F) << TAS2780_CHNL_0_AMP_LEVEL_SHIFT; +static constexpr uint8_t TAS2780_AMP_LEVEL_MAX = 0x14; // Codes above 20 are reserved + +static constexpr uint8_t TAS2780_DC_BLK0 = 0x04; // SAR Filter and DC Path Blocker +static constexpr uint8_t TAS2780_DC_BLK0_VBAT1S_MODE_MASK = (1 << 7); +static constexpr uint8_t TAS2780_DC_BLK1 = 0x05; // Record DC Blocker +static constexpr uint8_t TAS2780_DC_BLK1_RESET_VAL = 0x41; // Presence check, there is no WHO_AM_I register + +static constexpr uint8_t TAS2780_TDM_CFG2 = 0x0A; // TDM Configuration 2 +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SCFG_SHIFT = 4; +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SCFG_MASK = (3 << TAS2780_TDM_CFG2_RX_SCFG_SHIFT); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_WLEN_SHIFT = 2; +static constexpr uint8_t TAS2780_TDM_CFG2_RX_WLEN_MASK = (3 << TAS2780_TDM_CFG2_RX_WLEN_SHIFT); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_WLEN_32BIT = (3 << TAS2780_TDM_CFG2_RX_WLEN_SHIFT); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SLEN_MASK = (3 << 0); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SLEN_32BIT = 2; + +static constexpr uint8_t TAS2780_TDM_CFG5 = 0x0E; // TDM Configuration 5 +static constexpr uint8_t TAS2780_TDM_CFG5_TX_VSNS_EN_SLOT4 = 0x44; // vsns TX enable, slot 4 +static constexpr uint8_t TAS2780_TDM_CFG6 = 0x0F; // TDM Configuration 6 +static constexpr uint8_t TAS2780_TDM_CFG6_TX_ISNS_EN_SLOT0 = 0x40; // isns TX enable, slot 0 + +static constexpr uint8_t TAS2780_DVC = 0x1A; // Digital Volume Control +static constexpr uint8_t TAS2780_DVC_MAX_ATTEN = 200; // 0 dB (0x00) to -100 dB (0xC8) in 0.5 dB steps + +/* Interrupts */ +static constexpr uint8_t TAS2780_INT_MASK_ALL = 0xFF; // Mask all interrupts +static constexpr uint8_t TAS2780_INT_MASK0 = 0x3B; // Interrupt Mask 0 +static constexpr uint8_t TAS2780_INT_MASK1 = 0x3C; // Interrupt Mask 1 +static constexpr uint8_t TAS2780_INT_MASK1_0 = 0x3D; // Interrupt Mask 1_0 (INT_LTCH1_0 group) +static constexpr uint8_t TAS2780_INT_MASK2 = 0x40; // Interrupt Mask 2 +static constexpr uint8_t TAS2780_INT_MASK3 = 0x41; // Interrupt Mask 3 +static constexpr uint8_t TAS2780_INT_LTCH0 = 0x49; // Latched Interrupt Read-back 0, 1 and 1_0 follow +static constexpr uint8_t TAS2780_INT_LTCH2 = 0x4F; // Latched Interrupt Read-back 2 + +static constexpr uint8_t TAS2780_INT_CLK_CFG = 0x5C; // Clock Setting and IRQZ +static constexpr uint8_t TAS2780_INT_CLK_CFG_CLR_LATCH = (1 << 2); // Clear interrupt latches +static constexpr uint8_t TAS2780_INT_CLK_CFG_MODE_MASK = 0x03; // Trigger mode field mask +static constexpr uint8_t TAS2780_INT_CLK_CFG_MODE_LIVE = 0x00; // Trigger on any unmasked live interrupt +static constexpr uint8_t TAS2780_PVDD_UVLO = 0x71; // UVLO Threshold +static constexpr uint8_t TAS2780_PVDD_UVLO_2V76 = 0x03; // PVDD UVLO threshold = 2.76V + +/* PAGE 0x01 */ +static constexpr uint8_t TAS2780_INIT_0 = 0x17; // Initialization +static constexpr uint8_t TAS2780_INIT_0_VAL = 0xC8; // SARBurstMask=0, CMP_HYST_LP=1 +static constexpr uint8_t TAS2780_LSR = 0x19; // Modulation +static constexpr uint8_t TAS2780_LSR_PWM_MODE = 0x00; // PWM modulation mode +static constexpr uint8_t TAS2780_INIT_1 = 0x21; // Initialization +static constexpr uint8_t TAS2780_INIT_1_VAL = 0x00; // Disable comparator hysteresis +static constexpr uint8_t TAS2780_INIT_2 = 0x35; // Initialization +static constexpr uint8_t TAS2780_INIT_2_VAL = 0x74; // Noise minimized + +/* PAGE 0xFD */ +static constexpr uint8_t TAS2780_PAGE_FD_ACCESS = 0x0D; // Page 0xFD access unlock/lock register +static constexpr uint8_t TAS2780_PAGE_FD_ACCESS_UNLOCK = 0x0D; // Unlock page 0xFD access +static constexpr uint8_t TAS2780_PAGE_FD_ACCESS_LOCK = 0x00; // Lock page 0xFD access +static constexpr uint8_t TAS2780_INIT_3 = 0x3E; // Initialization +static constexpr uint8_t TAS2780_INIT_3_VAL = 0x4A; // Optimal Dmin + +// CDS_MODE (2 bits) and VBAT1S_MODE (1 bit) per power mode 0..3, packed so nothing lands in RAM on ESP8266: +// PWR_MODE0: 2/0, PWR_MODE1: 0/0, PWR_MODE2: 3/1, PWR_MODE3: 1/0 +static constexpr uint8_t POWER_MODE_CDS_MODES = 0x72; +static constexpr uint8_t POWER_MODE_VBAT1S_MODES = 0x04; + +// Latched interrupt bits per register (INT_LTCH0, 1, 1_0, 2, one byte each, low to high), split into faults +// and informational events; bits outside both masks are undefined. +static constexpr uint32_t TAS2780_INT_LTCH_ERROR_MASKS = 0x0FA058C7; +static constexpr uint32_t TAS2780_INT_LTCH_INFO_MASKS = 0x00002138; + +// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266. +static const LogString *fault_name(uint8_t reg, uint8_t bit) { + const uint8_t key = (reg << 3) | bit; + if (key == 0x00) + return LOG_STR("Over temperature error"); + if (key == 0x01) + return LOG_STR("Over current error"); + if (key == 0x02) + return LOG_STR("TDM Clock Error"); + if (key == 0x03) + return LOG_STR("Limiter active"); + if (key == 0x04) + return LOG_STR("PVDD below limiter inflection point"); + if (key == 0x05) + return LOG_STR("Limiter max attenuation"); + if (key == 0x06) + return LOG_STR("BOP infinite hold"); + if (key == 0x07) + return LOG_STR("BOP Mute"); + if (key == 0x08) + return LOG_STR("Gain limiter active"); + if (key == 0x0B) + return LOG_STR("Load Diagnostic mode fault status"); + if (key == 0x0D) + return LOG_STR("Load diagnostic complete"); + if (key == 0x0E) + return LOG_STR("OTP CRC error flag"); + if (key == 0x15) + return LOG_STR("VBAT1S Under Voltage"); + if (key == 0x17) + return LOG_STR("Internal PLL Clock Error"); + if (key == 0x18) + return LOG_STR("PVDD UVLO"); + if (key == 0x19) + return LOG_STR("Internal VBAT1S LDO Over Load"); + if (key == 0x1A) + return LOG_STR("Internal VBAT1S LDO Over Voltage"); + if (key == 0x1B) + return LOG_STR("Internal VBAT1S LDO Under Voltage"); + return nullptr; +} + +void TAS2780::setup() { + if (!this->init_()) { + this->mark_failed(); + return; + } + this->write_mode_ctrl_(TAS2780_MODE_CTRL_MODE_SFTW_SHTDWN); +} + +bool TAS2780::select_page_(uint8_t page) { + if (this->current_page_ == page) + return true; + if (!this->write_byte(TAS2780_PAGE_SELECT, page)) { + this->current_page_ = 0xFF; + return false; + } + this->current_page_ = page; + return true; +} + +bool TAS2780::update_bits_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t current; + if (!this->select_page_(TAS2780_PAGE_0) || !this->read_byte(reg, ¤t)) { + ESP_LOGE(TAG, "Failed to read register 0x%02X", reg); + return false; + } + return this->write_byte(reg, (current & ~mask) | (value & mask)); +} + +bool TAS2780::init_() { + // Software reset (must select page 0 first; reset invalidates page cache) + if (!this->select_page_(TAS2780_PAGE_0)) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + this->current_page_ = 0xFF; + this->applied_power_mode_ = 0xFF; // only a complete init makes the chip trustworthy again + this->reg(TAS2780_SW_RESET) = TAS2780_SW_RESET_CMD; + delay(1); + + uint8_t chd1; + if (!this->read_byte(TAS2780_DC_BLK1, &chd1)) { + ESP_LOGE(TAG, "I2C read failed during init"); + return false; + } + if (chd1 != TAS2780_DC_BLK1_RESET_VAL) { + ESP_LOGE(TAG, "Init failed (DC_BLK1=0x%02X, expected 0x%02X)", chd1, TAS2780_DC_BLK1_RESET_VAL); + return false; + } + + if (!this->select_page_(TAS2780_PAGE_0)) { + return false; + } + this->reg(TAS2780_TDM_CFG5) = TAS2780_TDM_CFG5_TX_VSNS_EN_SLOT4; + this->reg(TAS2780_TDM_CFG6) = TAS2780_TDM_CFG6_TX_ISNS_EN_SLOT0; + + if (!this->select_page_(TAS2780_PAGE_1)) { + return false; + } + this->reg(TAS2780_LSR) = TAS2780_LSR_PWM_MODE; + this->reg(TAS2780_INIT_0) = TAS2780_INIT_0_VAL; + this->reg(TAS2780_INIT_1) = TAS2780_INIT_1_VAL; + this->reg(TAS2780_INIT_2) = TAS2780_INIT_2_VAL; + + if (!this->select_page_(TAS2780_PAGE_FD)) { + return false; + } + this->reg(TAS2780_PAGE_FD_ACCESS) = TAS2780_PAGE_FD_ACCESS_UNLOCK; + this->reg(TAS2780_INIT_3) = TAS2780_INIT_3_VAL; + this->reg(TAS2780_PAGE_FD_ACCESS) = TAS2780_PAGE_FD_ACCESS_LOCK; + + if (!this->select_page_(TAS2780_PAGE_0)) { + return false; + } + if (!this->set_power_mode_(this->power_mode_)) + return false; + + // When Y bridge is used (eg. PWR_MODE1) PVDD UVLO threshold needs to be set 2.5 V above VBAT1S level. + // UVLO = 1.753V + val * 0.332V + this->reg(TAS2780_PVDD_UVLO) = TAS2780_PVDD_UVLO_2V76; + + // Mask all interrupt groups on the IRQZ pin, events are polled via update() + this->reg(TAS2780_INT_MASK0) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK1) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK1_0) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK2) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK3) = TAS2780_INT_MASK_ALL; + + if (!this->update_bits_(TAS2780_INT_CLK_CFG, TAS2780_INT_CLK_CFG_MODE_MASK, TAS2780_INT_CLK_CFG_MODE_LIVE)) + return false; + + // Software reset sets DVC back to 0 dB (full volume) + if (!this->apply_config()) + return false; + this->applied_power_mode_ = this->power_mode_; + return true; +} + +void TAS2780::activate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "Activating (PWR_MODE:%u)", this->power_mode_); + this->clear_latches_(); + if (this->power_mode_ != this->applied_power_mode_ && !this->reinit_()) + return; + this->active_ = this->write_mode_ctrl_(this->active_mode_()); +} + +void TAS2780::deactivate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "Deactivating"); + this->active_ = false; + this->write_mode_ctrl_(TAS2780_MODE_CTRL_MODE_SFTW_SHTDWN); +} + +void TAS2780::reset() { + if (this->is_failed()) + return; + if (this->reinit_() && this->active_) + this->activate(); +} + +bool TAS2780::reinit_() { + if (!this->init_()) { + ESP_LOGE(TAG, "Re-initialization failed"); + this->status_set_error(LOG_STR("Init failed")); + return false; + } + this->status_clear_error(); + return true; +} + +void TAS2780::set_power_mode(uint8_t power_mode) { + // Lambda-supplied values bypass schema validation; refuse before anything touches the chip + if (power_mode >= 4) { + ESP_LOGE(TAG, "Invalid power mode %u, must be 0-3", power_mode); + return; + } + this->power_mode_ = power_mode; +} + +bool TAS2780::set_power_mode_(uint8_t power_mode) { + uint8_t cds_mode = (POWER_MODE_CDS_MODES >> (power_mode * 2)) & 0x03; + uint8_t vbat1s_mode = (POWER_MODE_VBAT1S_MODES >> power_mode) & 0x01; + return this->update_bits_(TAS2780_CHNL_0, TAS2780_CHNL_0_CDS_MODE_MASK, cds_mode << TAS2780_CHNL_0_CDS_MODE_SHIFT) && + this->update_bits_(TAS2780_DC_BLK0, TAS2780_DC_BLK0_VBAT1S_MODE_MASK, vbat1s_mode ? 0xFF : 0); +} + +void TAS2780::clear_latches_() { + this->update_bits_(TAS2780_INT_CLK_CFG, TAS2780_INT_CLK_CFG_CLR_LATCH, TAS2780_INT_CLK_CFG_CLR_LATCH); +} + +// Returns true if any latched interrupt flag is set +bool TAS2780::log_error_states_() { + uint8_t latched[4]; + if (!this->select_page_(TAS2780_PAGE_0) || !this->read_bytes(TAS2780_INT_LTCH0, latched, 3) || + !this->read_byte(TAS2780_INT_LTCH2, &latched[3])) { + return false; + } + // LDMODE is a two-bit field in INT_LTCH1; report it once + if (latched[1] & (1 << 4)) + latched[1] = (latched[1] & ~(1 << 4)) | (1 << 3); + for (uint8_t reg = 0; reg < 4; reg++) { + uint8_t errors = latched[reg] & (TAS2780_INT_LTCH_ERROR_MASKS >> (reg * 8)); + uint8_t infos = latched[reg] & (TAS2780_INT_LTCH_INFO_MASKS >> (reg * 8)); + for (uint8_t bit = 0; bit < 8; bit++) { + if (errors & (1 << bit)) { + ESP_LOGE(TAG, "%s", LOG_STR_ARG(fault_name(reg, bit))); + } else if (infos & (1 << bit)) { + ESP_LOGD(TAG, "%s", LOG_STR_ARG(fault_name(reg, bit))); + } + } + } + return (latched[0] | latched[1] | latched[2] | latched[3]) != 0; +} + +void TAS2780::update() { + // Latches hold until cleared; without this the same events are logged on every update + if (this->log_error_states_()) + this->clear_latches_(); +} + +void TAS2780::dump_config() { + ESP_LOGCONFIG(TAG, "Audio Amplifier:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " Power Mode: %u\n" + " Amp Level: %u\n" + " Volume Range: %.2f - %.2f\n" + " Channel: %s", + this->power_mode_, this->amp_level_, this->vol_range_min_, this->vol_range_max_, + this->selected_channel_ == LEFT_CHANNEL ? LOG_STR_LITERAL("Left") + : this->selected_channel_ == RIGHT_CHANNEL ? LOG_STR_LITERAL("Right") + : LOG_STR_LITERAL("Mono Downmix")); +} + +bool TAS2780::write_mode_ctrl_(uint8_t mode) { + return this->update_bits_(TAS2780_MODE_CTRL, TAS2780_MODE_CTRL_MODE_MASK, mode); +} + +uint8_t TAS2780::active_mode_() const { + return this->is_muted_ ? TAS2780_MODE_CTRL_MODE_ACTIVE_MUTED : TAS2780_MODE_CTRL_MODE_ACTIVE; +} + +bool TAS2780::set_mute_(bool muted) { + bool previous = this->is_muted_; + this->is_muted_ = muted; + uint8_t mode_ctrl; + if (!this->select_page_(TAS2780_PAGE_0) || !this->read_byte(TAS2780_MODE_CTRL, &mode_ctrl)) { + ESP_LOGE(TAG, "Failed to read MODE_CTRL"); + this->is_muted_ = previous; + return false; + } + uint8_t current_mode = mode_ctrl & TAS2780_MODE_CTRL_MODE_MASK; + // Only switch between active/muted if device is active; don't wake from shutdown + if ((current_mode == TAS2780_MODE_CTRL_MODE_ACTIVE || current_mode == TAS2780_MODE_CTRL_MODE_ACTIVE_MUTED) && + !this->write_byte(TAS2780_MODE_CTRL, (mode_ctrl & ~TAS2780_MODE_CTRL_MODE_MASK) | this->active_mode_())) { + ESP_LOGE(TAG, "Failed to write MODE_CTRL"); + this->is_muted_ = previous; + return false; + } + return true; +} + +bool TAS2780::set_volume(float volume) { + float previous = this->volume_; + this->volume_ = clamp(volume, 0.0f, 1.0f); + if (!this->write_volume_()) { + this->volume_ = previous; + return false; + } + return true; +} + +bool TAS2780::write_volume_() { + // Lambda-supplied volume ranges are not bounded or ordered + float range_min = std::min(this->vol_range_min_, this->vol_range_max_); + float range_max = std::max(this->vol_range_min_, this->vol_range_max_); + float volume = clamp(std::lerp(range_min, range_max, this->volume_), 0.0f, 1.0f); + uint8_t dvc = remap(volume, 0.0f, 1.0f, TAS2780_DVC_MAX_ATTEN, 0); + ESP_LOGD(TAG, "Setting attenuation to: %u", dvc); + return this->select_page_(TAS2780_PAGE_0) && this->write_byte(TAS2780_DVC, dvc); +} + +bool TAS2780::apply_config() { return this->apply_amp_and_channel_config_() && this->write_volume_(); } + +bool TAS2780::apply_amp_and_channel_config_() { + // Lambda-supplied values bypass schema validation + if (this->amp_level_ > TAS2780_AMP_LEVEL_MAX) { + ESP_LOGW(TAG, "Amp level %u out of range, using %u", this->amp_level_, TAS2780_AMP_LEVEL_MAX); + this->amp_level_ = TAS2780_AMP_LEVEL_MAX; + } + ESP_LOGD(TAG, "Update amp to level idx: %u", this->amp_level_); + if (!this->update_bits_(TAS2780_CHNL_0, TAS2780_CHNL_0_AMP_LEVEL_MASK, + this->amp_level_ << TAS2780_CHNL_0_AMP_LEVEL_SHIFT)) { + return false; + } + return this->update_bits_( + TAS2780_TDM_CFG2, TAS2780_TDM_CFG2_RX_SCFG_MASK | TAS2780_TDM_CFG2_RX_WLEN_MASK | TAS2780_TDM_CFG2_RX_SLEN_MASK, + (this->selected_channel_ << TAS2780_TDM_CFG2_RX_SCFG_SHIFT) | TAS2780_TDM_CFG2_RX_WLEN_32BIT | + TAS2780_TDM_CFG2_RX_SLEN_32BIT); +} + +} // namespace esphome::tas2780 diff --git a/esphome/components/tas2780/tas2780.h b/esphome/components/tas2780/tas2780.h new file mode 100644 index 00000000000..443e2e849ed --- /dev/null +++ b/esphome/components/tas2780/tas2780.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +namespace esphome::tas2780 { + +// Values are the TDM_CFG2 RX_SCFG field. +enum ChannelSelect : uint8_t { LEFT_CHANNEL = 1, RIGHT_CHANNEL = 2, MONO_DWN_MIX = 3 }; + +class TAS2780 : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + void update() override; + + /// Software reset and re-initialization, leaving the amplifier on or off as last requested. + void reset(); + /// Activate with the configured power mode; re-initializes first when the mode changed. + void activate(); + void deactivate(); + /// Write the amp level, channel and volume range to the device. + bool apply_config(); + + bool set_mute_off() override { return this->set_mute_(false); } + bool set_mute_on() override { return this->set_mute_(true); } + bool set_volume(float volume) override; + + bool is_muted() override { return this->is_muted_; } + float volume() override { return this->volume_; } + + void set_amp_level(uint8_t amp_level) { this->amp_level_ = amp_level; } + void set_power_mode(uint8_t power_mode); + void set_vol_range_min(float min_val) { this->vol_range_min_ = min_val; } + void set_vol_range_max(float max_val) { this->vol_range_max_ = max_val; } + void set_selected_channel(ChannelSelect channel) { this->selected_channel_ = channel; } + + protected: + bool select_page_(uint8_t page); + bool update_bits_(uint8_t reg, uint8_t mask, uint8_t value); + bool init_(); + bool reinit_(); + bool set_power_mode_(uint8_t power_mode); + bool apply_amp_and_channel_config_(); + bool write_mode_ctrl_(uint8_t mode); + uint8_t active_mode_() const; + bool set_mute_(bool muted); + bool write_volume_(); + bool log_error_states_(); + void clear_latches_(); + + float volume_{0}; + float vol_range_min_{0.3f}; + float vol_range_max_{1.0f}; + uint8_t current_page_{0xFF}; + uint8_t power_mode_{2}; + uint8_t applied_power_mode_{0xFF}; + uint8_t amp_level_{8}; + ChannelSelect selected_channel_{MONO_DWN_MIX}; + bool active_{false}; +}; + +} // namespace esphome::tas2780 diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index da6edfd62ab..898aa945438 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -113,21 +113,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) - if CONF_RED_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_RED_CHANNEL]) - cg.add(var.set_red_sensor(sens)) - if CONF_GREEN_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_GREEN_CHANNEL]) - cg.add(var.set_green_sensor(sens)) - if CONF_BLUE_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_BLUE_CHANNEL]) - cg.add(var.set_blue_sensor(sens)) - if CONF_CLEAR_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_CLEAR_CHANNEL]) - cg.add(var.set_clear_sensor(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance_sensor(sens)) - if CONF_COLOR_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, var.set_red_sensor) + await sensors(CONF_GREEN_CHANNEL, var.set_green_sensor) + await sensors(CONF_BLUE_CHANNEL, var.set_blue_sensor) + await sensors(CONF_CLEAR_CHANNEL, var.set_clear_sensor) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, var.set_color_temperature_sensor) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 57a99f2830e..5888ce5e29b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } - // Remove before 2026.10.0 - ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0") - void bypass_before_arming() { this->auto_bypass_sensors_(); } #ifdef USE_BINARY_SENSOR /** Initialize the sensors vector with the specified capacity. diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 8f57df91c51..b2d6eca260c 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -2,7 +2,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import CONF_CONDITION, CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_CONDITION, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, +) from esphome.cpp_generator import LambdaExpression from .. import template_ns @@ -12,7 +18,11 @@ TemplateBinarySensor = template_ns.class_( ) CONFIG_SCHEMA = ( - binary_sensor.binary_sensor_schema(TemplateBinarySensor) + cv.with_visibility( + binary_sensor.binary_sensor_schema(TemplateBinarySensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Exclusive(CONF_LAMBDA, CONF_CONDITION): cv.returning_lambda, @@ -50,20 +60,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.template.publish", - binary_sensor.BinarySensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index c78a95e0e36..e1a089b44ca 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: + // User provided, not "= default": `new(p) TemplateBinarySensor()` would zero-fill .bss that is already zero. + TemplateBinarySensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; diff --git a/esphome/components/template/button/__init__.py b/esphome/components/template/button/__init__.py index e0101dfc8f3..9c6fa13c19b 100644 --- a/esphome/components/template/button/__init__.py +++ b/esphome/components/template/button/__init__.py @@ -1,10 +1,14 @@ from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE_CLASS from .. import template_ns TemplateButton = template_ns.class_("TemplateButton", button.Button) -CONFIG_SCHEMA = button.button_schema(TemplateButton) +CONFIG_SCHEMA = cv.with_visibility( + button.button_schema(TemplateButton), cv.Visibility.UI, CONF_DEVICE_CLASS +) async def to_code(config): diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index f64a85eef07..bd07b2258c1 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -6,6 +6,9 @@ namespace esphome::template_ { class TemplateButton final : public button::Button { public: + // User provided, not "= default": `new(p) TemplateButton()` would zero-fill .bss that is already zero. + TemplateButton() {} + // Implements the abstract `press_action` but the `on_press` trigger already handles the press. void press_action() override{}; }; diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index c39ea8f80e9..5f7627c5c38 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -28,8 +28,6 @@ from esphome.const import ( 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 @@ -53,11 +51,6 @@ 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 @@ -381,6 +374,7 @@ async def to_code(config: ConfigType) -> 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)) + cg.add(var.set_has_state(True)) CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( @@ -416,50 +410,31 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "climate.template.publish", - TemplateClimatePublishAction, CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_ + ), + automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_), + automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_ + ), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_ + ), + automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode), + automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction), + automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", climate.ClimateFanMode), + automation.ApplyField( + CONF_CUSTOM_FAN_MODE, "set_custom_fan_mode(StringRef({}))", cg.std_string + ), + automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", climate.ClimateSwingMode), + automation.ApplyField(CONF_PRESET, "set_preset", climate.ClimatePreset), + automation.ApplyField( + CONF_CUSTOM_PRESET, "set_custom_preset(StringRef({}))", cg.std_string + ), + automation.ApplyCall("publish_state()"), ) -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 diff --git a/esphome/components/template/climate/automation.h b/esphome/components/template/climate/automation.h deleted file mode 100644 index 49a79ace2f1..00000000000 --- a/esphome/components/template/climate/automation.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include "template_climate.h" -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template -class TemplateClimatePublishAction final : public Action, public Parented { - 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_ diff --git a/esphome/components/template/climate/template_climate.h b/esphome/components/template/climate/template_climate.h index 5448488c345..4cdf213315c 100644 --- a/esphome/components/template/climate/template_climate.h +++ b/esphome/components/template/climate/template_climate.h @@ -50,8 +50,7 @@ class TemplateClimate final : public climate::Climate, public Component { Trigger *get_set_preset_trigger() { return &this->set_preset_trigger_; } Trigger *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. + // Public so the climate.template.publish action and the initial_state codegen can set them. 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; } diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 7cb50df84c5..39df5affcf2 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, @@ -19,9 +20,6 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType from .. import template_ns @@ -38,7 +36,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - cover.cover_schema(TemplateCover) + cv.with_visibility( + cover.cover_schema(TemplateCover), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, @@ -115,17 +117,8 @@ async def to_code(config): # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both map to the position field. -_COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( - cover.ApplyField(CONF_STATE, "position", cg.float_), - cover.ApplyField(CONF_POSITION, "position", cg.float_), - cover.ApplyField(CONF_TILT, "tilt", cg.float_), - cover.ApplyField(CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), -) - - -@automation.register_action( +automation.register_apply_action( "cover.template.publish", - cover.CoverPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(cover.Cover), @@ -137,22 +130,11 @@ _COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( cv.Optional(CONF_TILT): cv.templatable(cv.zero_to_one_float), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField(CONF_TILT, "tilt = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", cover.CoverOperation + ), + automation.ApplyCall("publish_state()"), ) -async def cover_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - # Mutates Cover fields directly (no CoverCall) since publish is a state - # push, not a control request. - return await cover.build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_PUBLISH_FIELDS, - prefix_args=[(cover.Cover.operator("ptr"), "cover")], - statement_fn=lambda field, expr: f"cover->{field} = {expr};", - ) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index d5e0967e1e3..1bf057da5b5 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,8 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } -void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; } @@ -117,10 +115,6 @@ CoverTraits TemplateCover::get_traits() { } Trigger *TemplateCover::get_position_trigger() { return &this->position_trigger_; } Trigger *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; } -void TemplateCover::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateCover::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateCover::set_has_position(bool has_position) { this->has_position_ = has_position; } -void TemplateCover::set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } void TemplateCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 20c092cda79..d3096ba86f9 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -25,12 +25,12 @@ class TemplateCover final : public cover::Cover, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); - void set_optimistic(bool optimistic); - void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_tilt(bool has_tilt); - void set_has_toggle(bool has_toggle); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/event/__init__.py b/esphome/components/template/event/__init__.py index cf9c7f4c3df..bdcbd456d5d 100644 --- a/esphome/components/template/event/__init__.py +++ b/esphome/components/template/event/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import event import esphome.config_validation as cv -from esphome.const import CONF_EVENT_TYPES +from esphome.const import CONF_DEVICE_CLASS, CONF_EVENT_TYPES from .. import template_ns @@ -9,7 +9,9 @@ CODEOWNERS = ["@nohat"] TemplateEvent = template_ns.class_("TemplateEvent", event.Event, cg.Component) -CONFIG_SCHEMA = event.event_schema(TemplateEvent).extend( +CONFIG_SCHEMA = cv.with_visibility( + event.event_schema(TemplateEvent), cv.Visibility.UI, CONF_DEVICE_CLASS +).extend( { cv.Required(CONF_EVENT_TYPES): cv.ensure_list(cv.string_strict), } diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index fe83dc9f34b..3d2d9a9efe2 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -5,6 +5,10 @@ namespace esphome::template_ { -class TemplateEvent final : public Component, public event::Event {}; +class TemplateEvent final : public Component, public event::Event { + public: + // User provided, not "= default": `new(p) TemplateEvent()` would zero-fill .bss that is already zero. + TemplateEvent() {} +}; } // namespace esphome::template_ diff --git a/esphome/components/template/lock/__init__.py b/esphome/components/template/lock/__init__.py index d8bd9d16c66..95a359c67ce 100644 --- a/esphome/components/template/lock/__init__.py +++ b/esphome/components/template/lock/__init__.py @@ -4,7 +4,6 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, - CONF_ID, CONF_LAMBDA, CONF_LOCK_ACTION, CONF_OPEN_ACTION, @@ -17,12 +16,6 @@ from .. import template_ns TemplateLock = template_ns.class_("TemplateLock", lock.Lock, cg.Component) -TemplateLockPublishAction = template_ns.class_( - "TemplateLockPublishAction", - automation.Action, - cg.Parented.template(TemplateLock), -) - def validate(config): if not config[CONF_OPTIMISTIC] and ( @@ -80,9 +73,8 @@ async def to_code(config): cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) -@automation.register_action( +automation.register_apply_action( "lock.template.publish", - TemplateLockPublishAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(TemplateLock), @@ -90,11 +82,5 @@ async def to_code(config): }, key=CONF_STATE, ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", lock.LockState), ) -async def lock_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, lock.LockState) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h deleted file mode 100644 index a979291b785..00000000000 --- a/esphome/components/template/lock/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "template_lock.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateLockPublishAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(lock::LockState, state) - - void play(const Ts &...x) override { this->parent_->publish_state(this->state_.value(x...)); } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 6e73623ae9b..4a293aab858 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -47,7 +47,6 @@ void TemplateLock::open_latch() { this->prev_trigger_ = &this->open_trigger_; this->open_trigger_.trigger(); } -void TemplateLock::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } float TemplateLock::get_setup_priority() const { return setup_priority::HARDWARE; } void TemplateLock::dump_config() { LOG_LOCK("", "Template Lock", this); diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index 03e3e86d88e..9b0a1ffe984 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -18,7 +18,7 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *get_lock_trigger() { return &this->lock_trigger_; } Trigger<> *get_unlock_trigger() { return &this->unlock_trigger_; } Trigger<> *get_open_trigger() { return &this->open_trigger_; } - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py index 2f4c9cbffe6..3b6485fec3d 100644 --- a/esphome/components/template/number/__init__.py +++ b/esphome/components/template/number/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, CONF_STEP, + CONF_UNIT_OF_MEASUREMENT, ) from .. import template_ns @@ -46,7 +48,12 @@ def validate(config): CONFIG_SCHEMA = cv.All( - number.number_schema(TemplateNumber) + cv.with_visibility( + number.number_schema(TemplateNumber), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + ) .extend( { cv.Required(CONF_MAX_VALUE): cv.float_, diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 5da6d732bd4..1cc28a36d37 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -23,6 +23,8 @@ void update_lambda(BaseTemplateSelect *sel_comp, const optional &va template class TemplateSelect : public BaseTemplateSelect { public: + // User provided, not "= default": `new(p) TemplateSelect()` would zero-fill .bss that is already zero. + TemplateSelect() {} template void set_lambda(F &&f) { if constexpr (HAS_LAMBDA) { this->f_.set(std::forward(f)); diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 0c875bba0fb..f831f87d92b 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -2,7 +2,16 @@ from esphome import automation import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) from .. import template_ns @@ -11,9 +20,14 @@ TemplateSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - sensor.sensor_schema( - TemplateSensor, - accuracy_decimals=1, + cv.with_visibility( + sensor.sensor_schema(TemplateSensor, accuracy_decimals=1), + cv.Visibility.UI, + CONF_UNIT_OF_MEASUREMENT, + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_FORCE_UPDATE, ) .extend( { @@ -35,20 +49,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "sensor.template.publish", - sensor.SensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(sensor.Sensor), cv.Required(CONF_STATE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.float_), ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 825a2b4ffaa..68e22372679 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateSensor()` would zero-fill .bss that is already zero. + TemplateSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index ca986365ede..f8e509faf36 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -4,6 +4,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC, @@ -31,7 +32,11 @@ def validate(config): CONFIG_SCHEMA = cv.All( - switch.switch_schema(TemplateSwitch) + cv.with_visibility( + switch.switch_schema(TemplateSwitch), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, @@ -67,24 +72,20 @@ async def to_code(config): await automation.build_automation( var.get_turn_on_trigger(), [], config[CONF_TURN_ON_ACTION] ) - cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) + # optimistic_ and assumed_state_ are false in C++; only emit setters to turn them on. + if config[CONF_OPTIMISTIC]: + cg.add(var.set_optimistic(True)) + if config[CONF_ASSUMED_STATE]: + cg.add(var.set_assumed_state(True)) -@automation.register_action( +automation.register_apply_action( "switch.template.publish", - switch.SwitchPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(switch.Switch), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def switch_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 05288b2d4e0..729db370531 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -29,7 +29,6 @@ void TemplateSwitch::write_state(bool state) { if (this->optimistic_) this->publish_state(state); } -void TemplateSwitch::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } bool TemplateSwitch::assumed_state() { return this->assumed_state_; } float TemplateSwitch::get_setup_priority() const { return setup_priority::HARDWARE - 2.0f; } Trigger<> *TemplateSwitch::get_turn_on_trigger() { return &this->turn_on_trigger_; } @@ -43,17 +42,12 @@ void TemplateSwitch::setup() { if (initial_state.has_value()) { ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value())); // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void TemplateSwitch::dump_config() { LOG_SWITCH("", "Template Switch", this); ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } } // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 1714b4f72b9..6dc073e4b37 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -17,8 +17,8 @@ class TemplateSwitch final : public switch_::Switch, public Component { template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); - void set_optimistic(bool optimistic); - void set_assumed_state(bool assumed_state); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void loop() override; float get_setup_priority() const override; @@ -29,6 +29,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { void write_state(bool state) override; TemplateLambda f_; + // Codegen only emits these setters to turn them on bool optimistic_{false}; bool assumed_state_{false}; Trigger<> turn_on_trigger_; diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index ddbdd6dadb7..32e616e6f9f 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -1,9 +1,8 @@ from esphome import automation import esphome.codegen as cg from esphome.components import text_sensor -from esphome.components.text_sensor import TextSensorPublishAction import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE from .. import template_ns @@ -12,7 +11,11 @@ TemplateTextSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - text_sensor.text_sensor_schema() + cv.with_visibility( + text_sensor.text_sensor_schema(), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.GenerateID(): cv.declare_id(TemplateTextSensor), @@ -34,20 +37,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "text_sensor.template.publish", - TextSensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(text_sensor.TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.std_string), ) -async def text_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index 0538a7ec211..8f03f78be4f 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -9,6 +9,8 @@ namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateTextSensor()` would zero-fill .bss that is already zero. + TemplateTextSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; diff --git a/esphome/components/template/time/__init__.py b/esphome/components/template/time/__init__.py new file mode 100644 index 00000000000..0332198e78d --- /dev/null +++ b/esphome/components/template/time/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import time as time_ +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.types import ConfigType + +from .. import template_ns + +TemplateRealTimeClock = template_ns.class_("TemplateRealTimeClock", time_.RealTimeClock) + + +CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TemplateRealTimeClock), + cv.Required(CONF_LAMBDA): cv.returning_lambda, + cv.Optional(time_.CONF_ON_TIME_SYNC): cv.invalid( + "on_time_sync is not supported for template time, use on the source clock instead." + ), + } +).extend(cv.polling_component_schema("never")) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await time_.register_time(var, config) + + template_ = await cg.process_lambda( + config[CONF_LAMBDA], [], return_type=cg.optional.template(cg.int64) + ) + cg.add(var.set_template(template_)) diff --git a/esphome/components/template/time/template_real_time_clock.cpp b/esphome/components/template/time/template_real_time_clock.cpp new file mode 100644 index 00000000000..4b523def251 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.cpp @@ -0,0 +1,20 @@ +#include "template_real_time_clock.h" + +namespace esphome::template_ { + +static const char *const TAG = "template.time"; + +time_t TemplateRealTimeClock::timestamp_now() { + auto val = this->f_.call(); + if (val.has_value()) { + auto value = *val; + if (value < 0 || value > std::numeric_limits::max()) { + ESP_LOGW(TAG, "timestamp value out of range: %lld", static_cast(value)); + return 0; + } + return static_cast(value); + } + return 0; +} + +} // namespace esphome::template_ diff --git a/esphome/components/template/time/template_real_time_clock.h b/esphome/components/template/time/template_real_time_clock.h new file mode 100644 index 00000000000..ec381115d63 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/components/time/real_time_clock.h" +#include "esphome/core/template_lambda.h" + +namespace esphome::template_ { + +class TemplateRealTimeClock : public time::RealTimeClock { + public: + template void set_template(F &&f) { this->f_.set(std::forward(f)); } + + /// The time is computed on demand and never written to the system clock, so there is nothing to poll. + void update() override {} + + /// Returns 0 (an invalid time) if the lambda gives no value. + time_t timestamp_now() override; + + protected: + TemplateLambda f_; +}; + +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index a2d0c198805..a7fd6f7632c 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -6,7 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, - CONF_ID, + CONF_DEVICE_CLASS, CONF_LAMBDA, CONF_OPEN_ACTION, CONF_OPTIMISTIC, @@ -21,10 +21,6 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) -TemplateValvePublishAction = template_ns.class_( - "TemplateValvePublishAction", automation.Action, cg.Parented.template(TemplateValve) -) - TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") RESTORE_MODES = { "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, @@ -36,7 +32,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - valve.valve_schema(TemplateValve) + cv.with_visibility( + valve.valve_schema(TemplateValve), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, @@ -99,9 +99,10 @@ async def to_code(config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) -@automation.register_action( +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both write the position. +automation.register_apply_action( "valve.template.publish", - TemplateValvePublishAction, cv.Schema( { cv.GenerateID(): cv.use_id(TemplateValve), @@ -112,20 +113,10 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", valve.ValveOperation + ), + automation.ApplyCall("publish_state()"), ) -async def valve_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, cg.float_) - cg.add(var.set_position(template_)) - if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, cg.float_) - cg.add(var.set_position(template_)) - if current_operation_config := config.get(CONF_CURRENT_OPERATION): - template_ = await cg.templatable( - current_operation_config, args, valve.ValveOperation - ) - cg.add(var.set_current_operation(template_)) - return var diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h deleted file mode 100644 index ec9d784ab65..00000000000 --- a/esphome/components/template/valve/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "template_valve.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateValvePublishAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) - - void play(const Ts &...x) override { - if (this->position_.has_value()) - this->parent_->position = this->position_.value(x...); - if (this->current_operation_.has_value()) - this->parent_->current_operation = this->current_operation_.value(x...); - this->parent_->publish_state(); - } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 3ebeec12856..50906876399 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,8 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } -void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateValve::get_open_trigger() { return &this->open_trigger_; } @@ -111,10 +109,6 @@ ValveTraits TemplateValve::get_traits() { Trigger *TemplateValve::get_position_trigger() { return &this->position_trigger_; } -void TemplateValve::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateValve::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateValve::set_has_position(bool has_position) { this->has_position_ = has_position; } - void TemplateValve::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 76c4630aa02..504fdb2fbaf 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -23,11 +23,11 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_stop_trigger(); Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); - void set_optimistic(bool optimistic); - void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_toggle(bool has_toggle); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateValveRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 092df6fdca3..7718688d68a 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -1,6 +1,8 @@ #include "template_water_heater.h" #include "esphome/core/log.h" +#include + namespace esphome::template_ { static const char *const TAG = "template.water_heater"; @@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { void TemplateWaterHeater::loop() { bool changed = false; + // NAN is passed through so a source that has no value yet shows as unknown, but NAN never + // equals NAN, so an already-NAN value must not count as a change or it would republish forever. auto curr_temp = this->current_temperature_f_.call(); if (curr_temp.has_value()) { - if (*curr_temp != this->current_temperature_) { + if (*curr_temp != this->current_temperature_ && + !(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) { this->current_temperature_ = *curr_temp; changed = true; } @@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() { auto target_temp = this->target_temperature_f_.call(); if (target_temp.has_value()) { - if (*target_temp != this->target_temperature_) { + if (*target_temp != this->target_temperature_ && + !(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) { this->target_temperature_ = *target_temp; changed = true; } @@ -85,7 +91,11 @@ void TemplateWaterHeater::loop() { } } - if (changed) { + // The first value always publishes, even one equal to the default (mode OFF, say), or the + // entity would report unknown forever. + const bool has_value = + curr_temp.has_value() || target_temp.has_value() || new_mode.has_value() || away.has_value() || is_on.has_value(); + if (changed || (has_value && !this->has_state())) { this->publish_state(); } } diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index e010e2c2922..2f82f19ff54 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,13 +13,13 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] @@ -34,9 +34,6 @@ TextStateTrigger = text_ns.class_( "TextStateTrigger", automation.Trigger.template(cg.std_string) ) -# Actions -TextSetAction = text_ns.class_("TextSetAction", automation.Action) - # Conditions TextMode = text_ns.enum("TextMode") @@ -160,24 +157,13 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "text.set", - TextSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.std_string), + call="make_call", ) -async def text_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.std_string) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index 916d86340d4..bd6fb3cd23c 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -13,19 +13,4 @@ class TextStateTrigger final : public Trigger { } }; -template class TextSetAction final : public Action { - public: - explicit TextSetAction(Text *text) : text_(text) {} - TEMPLATABLE_VALUE(std::string, value) - - void play(const Ts &...x) override { - auto call = this->text_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Text *text_; -}; - } // namespace esphome::text diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b72..08870db64aa 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -20,14 +20,16 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_TIMESTAMP, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType from esphome.util import Registry DEVICE_CLASSES = [ @@ -43,13 +45,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorPublishAction = text_sensor_ns.class_( - "TextSensorPublishAction", automation.Action -) -TextSensorStateCondition = text_sensor_ns.class_( - "TextSensorStateCondition", automation.Condition -) - FILTER_REGISTRY = Registry() validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) @@ -148,7 +143,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } @@ -236,27 +233,28 @@ async def new_text_sensor(config, *args): return var +def sub_text_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_text_sensor.""" + return SubEntities(new_text_sensor, config, parent) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(text_sensor_ns.using) -@automation.register_condition( +automation.register_apply_condition( "text_sensor.state", - TextSensorStateCondition, cv.Schema( { cv.Required(CONF_ID): cv.use_id(TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), + automation.ApplyCall("state == {}", ((CONF_STATE, cg.std_string),)), ) -async def text_sensor_state_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - templ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(templ)) - return var FILTER_SOURCE_FILES = filter_source_files_from_defines( diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h deleted file mode 100644 index 628b9b84a0f..00000000000 --- a/esphome/components/text_sensor/automation.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#include - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::text_sensor { - -class TextSensorStateTrigger final : public Trigger { - public: - explicit TextSensorStateTrigger(TextSensor *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class TextSensorStateRawTrigger final : public Trigger { - public: - explicit TextSensorStateRawTrigger(TextSensor *parent) { - parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -template class TextSensorStateCondition final : public Condition { - public: - explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(std::string, state) - - bool check(const Ts &...x) override { return this->parent_->state == this->state_.value(x...); } - - protected: - TextSensor *parent_; -}; - -template class TextSensorPublishAction final : public Action { - public: - TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(std::string, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - TextSensor *sensor_; -}; - -} // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 0e7364bf980..5041ebc4e08 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,7 +29,8 @@ class TextSensor : public EntityBase { public: std::string state; - TextSensor() = default; + // User provided, not "= default": `new(p) TextSensor()` would zero-fill .bss that is already zero. + TextSensor() {} ~TextSensor() = default; /// Getter-syntax for .state. diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py new file mode 100644 index 00000000000..6ba821b1ab8 --- /dev/null +++ b/esphome/components/tfluna/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@candrews"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +tfluna_ns = cg.esphome_ns.namespace("tfluna") + +TFLunaComponent = tfluna_ns.class_("TFLuna", cg.PollingComponent, i2c.I2CDevice) + +CONF_TFLUNA_ID = "tfluna_id" +CONF_TIMESTAMP = "timestamp" + +FACTORY_DEFAULT_ADDRESS = 0x10 + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TFLunaComponent), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(FACTORY_DEFAULT_ADDRESS)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tfluna/button/__init__.py b/esphome/components/tfluna/button/__init__.py new file mode 100644 index 00000000000..753ac7de333 --- /dev/null +++ b/esphome/components/tfluna/button/__init__.py @@ -0,0 +1,67 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FACTORY_RESET, + CONF_RESTART, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RESTART, + ICON_RESTART_ALERT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import CONF_TFLUNA_ID, FACTORY_DEFAULT_ADDRESS, TFLunaComponent, tfluna_ns + +DEPENDENCIES = ["tfluna"] + +ResetButton = tfluna_ns.class_("ResetButton", button.Button) +RestartButton = tfluna_ns.class_("RestartButton", button.Button) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_FACTORY_RESET): button.button_schema( + ResetButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ), + cv.Optional(CONF_RESTART): button.button_schema( + RestartButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ), +} + + +def _validate_factory_default_address(config: ConfigType) -> ConfigType: + if config.get(CONF_ADDRESS) != FACTORY_DEFAULT_ADDRESS: + raise cv.Invalid( + f"'{CONF_FACTORY_RESET}' requires the TF-Luna to use its factory default " + f"I2C address 0x{FACTORY_DEFAULT_ADDRESS:02X}, as a factory reset " + "restores that address", + path=[CONF_ADDRESS], + ) + return config + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_FACTORY_RESET in config: + fv.id_declaration_match_schema(_validate_factory_default_address)( + config[CONF_TFLUNA_ID] + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/tfluna/button/reset_button.cpp b/esphome/components/tfluna/button/reset_button.cpp new file mode 100644 index 00000000000..b973f668642 --- /dev/null +++ b/esphome/components/tfluna/button/reset_button.cpp @@ -0,0 +1,7 @@ +#include "reset_button.h" + +namespace esphome::tfluna { + +void ResetButton::press_action() { this->parent_->factory_reset(); } + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/reset_button.h b/esphome/components/tfluna/button/reset_button.h new file mode 100644 index 00000000000..a52f479eea0 --- /dev/null +++ b/esphome/components/tfluna/button/reset_button.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../tfluna.h" + +namespace esphome::tfluna { + +class ResetButton : public button::Button, public Parented { + protected: + void press_action() override; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/restart_button.cpp b/esphome/components/tfluna/button/restart_button.cpp new file mode 100644 index 00000000000..546be2742d8 --- /dev/null +++ b/esphome/components/tfluna/button/restart_button.cpp @@ -0,0 +1,7 @@ +#include "restart_button.h" + +namespace esphome::tfluna { + +void RestartButton::press_action() { this->parent_->restart(); } + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/restart_button.h b/esphome/components/tfluna/button/restart_button.h new file mode 100644 index 00000000000..7caaebe6b38 --- /dev/null +++ b/esphome/components/tfluna/button/restart_button.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../tfluna.h" + +namespace esphome::tfluna { + +class RestartButton : public button::Button, public Parented { + protected: + void press_action() override; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py new file mode 100644 index 00000000000..3fb9ed20a66 --- /dev/null +++ b/esphome/components/tfluna/sensor.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_ARROW_EXPAND_VERTICAL, + ICON_SIGNAL, + ICON_THERMOMETER, + ICON_TIMER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_CENTIMETER, + UNIT_MILLISECOND, +) +from esphome.types import ConfigType + +from . import CONF_TFLUNA_ID, CONF_TIMESTAMP, TFLunaComponent + +DEPENDENCIES = ["tfluna"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_ARROW_EXPAND_VERTICAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + icon=ICON_SIGNAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIMESTAMP): sensor.sensor_schema( + icon=ICON_TIMER, + accuracy_decimals=0, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_SIGNAL_STRENGTH, hub.set_signal_strength_sensor) + await sensors(CONF_TIMESTAMP, hub.set_timestamp_sensor) diff --git a/esphome/components/tfluna/text_sensor.py b/esphome/components/tfluna/text_sensor.py new file mode 100644 index 00000000000..6844f3a7267 --- /dev/null +++ b/esphome/components/tfluna/text_sensor.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_TFLUNA_ID, TFLunaComponent + +DEPENDENCIES = ["tfluna"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp new file mode 100644 index 00000000000..1466ba89470 --- /dev/null +++ b/esphome/components/tfluna/tfluna.cpp @@ -0,0 +1,158 @@ +#include "tfluna.h" +#include +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::tfluna { + +// see https://files.waveshare.com/upload/a/ac/SJ-PM-TF-Luna_A05_Product_Manual.pdf +static constexpr uint8_t SAVE_REGISTER = 0x20; +static constexpr uint8_t VERSION_REVISION_REGISTER = 0x0A; +static constexpr uint8_t DISTANCE_LOW_REGISTER = 0x00; +static constexpr uint8_t RESTORE_FACTORY_DEFAULTS_REGISTER = 0x29; +static constexpr uint8_t SHUTDOWN_REBOOT_REGISTER = 0x21; +static constexpr uint8_t MODE_REGISTER = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24; +static constexpr uint8_t MAX_READ_ATTEMPTS = 5; +static constexpr uint8_t READ_RETRY_MS = 5; +static const char *const TAG = "tfluna"; +static const char *const READ_RETRY_TIMEOUT = "read_retry"; + +void TFLuna::dump_config() { + ESP_LOGCONFIG(TAG, "TF-Luna (i2c):"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Firmware: %s", this->version_); + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Distance:", this->distance_sensor_); + LOG_SENSOR(" ", "Temperature:", this->temperature_sensor_); + LOG_SENSOR(" ", "Signal Strength:", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Timestamp:", this->timestamp_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Version:", this->version_text_sensor_); +#endif +#ifdef USE_BUTTON + LOG_BUTTON(" ", "Factory Reset:", this->reset_button_); + LOG_BUTTON(" ", "Restart:", this->restart_button_); +#endif +} + +void TFLuna::setup() { + uint8_t mode; + if (!this->read_byte(MODE_REGISTER, &mode)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + if (mode != MODE_TRIGGER) { + if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + + if (!this->write_byte(SAVE_REGISTER, 1)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + } + + uint8_t raw_version[3]; + if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } else { + snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version_); + } +#endif + } +} + +bool TFLuna::read_data_() { + uint8_t buf[8]; + if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return false; + } + // Layout: + // buf[0..1] = distance (LE), buf[2..3] = signal (LE), + // buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE) + uint16_t distance = encode_uint16(buf[1], buf[0]); + uint16_t signal_strength = encode_uint16(buf[3], buf[2]); + int16_t temperature_raw = encode_uint16(buf[5], buf[4]); + uint16_t timestamp = encode_uint16(buf[7], buf[6]); + + if (timestamp == this->previous_timestamp_) { + return false; + } + +#ifdef USE_SENSOR + if (this->timestamp_sensor_ != nullptr) { + this->timestamp_sensor_->publish_state(timestamp); + } + if (this->distance_sensor_ != nullptr) { + // distance is unreliable when strength is below 100 or saturated at 0xFFFF + this->distance_sensor_->publish_state(signal_strength < 100 || signal_strength == 0xFFFF ? NAN : distance); + } + + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->publish_state(temperature_raw / (float) 100); + } + + if (this->signal_strength_sensor_ != nullptr) { + this->signal_strength_sensor_->publish_state(signal_strength); + } +#endif + this->status_clear_warning(); + this->previous_timestamp_ = timestamp; + return true; +} + +void TFLuna::read_data_timeout_() { + if (this->read_data_()) { + this->attempt_ = 0; + } else { + if (this->attempt_ < MAX_READ_ATTEMPTS) { + this->attempt_++; + this->set_timeout(READ_RETRY_TIMEOUT, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); + } else { + this->status_set_warning("Hung device, restarting..."); + this->restart(); + } + } +} + +void TFLuna::update() { + this->cancel_timeout(READ_RETRY_TIMEOUT); + this->attempt_ = 0; + if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + this->read_data_timeout_(); +} + +void TFLuna::factory_reset() { + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + this->status_set_warning("Factory reset issued; waiting for device to become ready"); + this->set_timeout("_setup", 100, [this]() { this->setup(); }); +} + +void TFLuna::restart() { + if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h new file mode 100644 index 00000000000..0cc866ef1df --- /dev/null +++ b/esphome/components/tfluna/tfluna.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#include "esphome/components/i2c/i2c.h" + +namespace esphome::tfluna { + +class TFLuna : public i2c::I2CDevice, public PollingComponent { +#ifdef USE_SENSOR + SUB_SENSOR(distance) + SUB_SENSOR(temperature) + SUB_SENSOR(signal_strength) + SUB_SENSOR(timestamp) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(version) +#endif +#ifdef USE_BUTTON + SUB_BUTTON(reset) + SUB_BUTTON(restart) +#endif + public: + void setup() override; + + float get_setup_priority() const override { return setup_priority::LATE; } + + void dump_config() override; + + void update() override; + + void factory_reset(); + + void restart(); + + protected: + char version_[12] = "unknown"; + + uint16_t previous_timestamp_ = 0; + + [[nodiscard]] bool read_data_(); + + void read_data_timeout_(); + + uint8_t attempt_ = 0; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index c660e5fcef8..ed42312a26b 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -82,18 +82,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e830d359c64..f64673e13fa 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1304,8 +1304,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { this->default_custom_preset_ = nullptr; } -void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4dc2a74d8e5..b7d46eae227 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -92,7 +92,7 @@ class ThermostatClimate final : public climate::Climate, public Component { void loop() override; void set_default_preset(const char *custom_preset); - void set_default_preset(climate::ClimatePreset preset); + void set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { this->on_boot_restore_from_ = on_boot_restore_from; } diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index ecc448a96a7..cedb9b3c9b5 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -6,7 +6,6 @@ import logging import tzlocal from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_defines @@ -18,7 +17,6 @@ from esphome.const import ( CONF_DAYS_OF_WEEK, CONF_HOUR, CONF_HOURS, - CONF_ID, CONF_MINUTE, CONF_MINUTES, CONF_MONTHS, @@ -49,7 +47,6 @@ time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component) SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) -TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition) # C++ types for pre-parsed timezone struct generation DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) @@ -469,18 +466,15 @@ async def to_code(config): cg.add_global(time_ns.using) -@automation.register_condition( +automation.register_apply_condition( "time.has_time", - TimeHasTimeCondition, cv.Schema( { cv.GenerateID(): cv.use_id(RealTimeClock), } ), + "now().is_valid()", ) -async def time_has_time_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) # posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index c449309c9ff..f98adb67b5b 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -29,7 +29,7 @@ class RealTimeClock : public PollingComponent { ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } /// Get the current time as the UTC epoch since January 1st 1970. - time_t timestamp_now() { return ::time(nullptr); } + virtual time_t timestamp_now() { return ::time(nullptr); } template void add_on_time_sync_callback(F &&callback) { this->time_sync_callback_.add(std::forward(callback)); @@ -44,13 +44,4 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition final : public Condition { - public: - TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} - bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } - - protected: - RealTimeClock *parent_; -}; - } // namespace esphome::time diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 53c4ab00734..1b819088cad 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,4 +1,4 @@ -from esphome import final_validate as fv +from esphome import automation, final_validate as fv, pins import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -17,12 +17,15 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +CONF_ON_MOUNT = "on_mount" +CONF_ON_UNMOUNT = "on_unmount" CONF_USB_LANG_ID = "usb_lang_id" CONF_USB_MANUFACTURER_STR = "usb_manufacturer_str" CONF_USB_PRODUCT_ID = "usb_product_id" CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # Components that provide a USB device class (CDC, HID, MSC, ...) on top of # tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in @@ -34,6 +37,19 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_MOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_UNMOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -44,6 +60,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_USB_MANUFACTURER_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_PRODUCT_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_SERIAL_STR, default=""): cv.string, + # esp_tinyusb monitors VBUS on the S31 through a GPIO interrupt and needs + # the GPIO ISR service installed first, which would collide with the esp32 + # platform's own lazy install and disable other interrupts. The other + # variants watch the pin in the OTG hardware. + cv.Optional(CONF_VBUS_MONITOR_PIN): cv.All( + pins.internal_gpio_input_pin_number, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32S31], msg_prefix=CONF_VBUS_MONITOR_PIN + ), + ), + cv.Optional(CONF_ON_MOUNT): automation.validate_automation({}), + cv.Optional(CONF_ON_UNMOUNT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -93,9 +121,20 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) + if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: + cg.add(var.set_vbus_monitor_pin(vbus_pin)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) + + +automation.register_apply_condition( + "tinyusb.is_mounted", + cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), + "is_mounted()", +) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index c8c36f0ffb6..3fab9de0086 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -9,6 +9,14 @@ namespace esphome::tinyusb { static const char *const TAG = "tinyusb"; +// Runs on the TinyUSB task: only wake the main loop, which reads the state and runs +// the automations. +static void tinyusb_event_cb(tinyusb_event_t *event, void *arg) { + if (event->id == TINYUSB_EVENT_ATTACHED || event->id == TINYUSB_EVENT_DETACHED) { + static_cast(arg)->enable_loop_soon_any_context(); + } +} + void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { @@ -21,6 +29,12 @@ void TinyUSB::setup() { this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; + // Without VBUS monitoring the OTG core only sees a cable pull as the bus going idle + // (a suspend), so TinyUSB never reports a detach and stays "mounted". + if (this->vbus_monitor_pin_ >= 0) { + this->tusb_cfg_.phy.self_powered = true; + this->tusb_cfg_.phy.vbus_monitor_io = this->vbus_monitor_pin_; + } this->tusb_cfg_.descriptor = { .device = &this->usb_descriptor_, .string = this->string_descriptor_, @@ -42,11 +56,26 @@ void TinyUSB::setup() { } #endif + this->tusb_cfg_.event_cb = tinyusb_event_cb; + this->tusb_cfg_.event_arg = this; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); + return; } + // loop() only reports mount changes; the mount hooks wake it when one happens. + this->disable_loop(); +} + +void TinyUSB::loop() { + const bool mounted = tud_mounted(); + if (mounted != this->last_reported_mounted_) { + this->last_reported_mounted_ = mounted; + ESP_LOGD(TAG, "USB host %s", mounted ? LOG_STR_LITERAL("mounted") : LOG_STR_LITERAL("unmounted")); + this->mount_state_callback_.call(mounted); + } + this->disable_loop(); } void TinyUSB::dump_config() { @@ -56,9 +85,12 @@ void TinyUSB::dump_config() { " Vendor ID: 0x%04X\n" " Manufacturer: '%s'\n" " Product: '%s'\n" - " Serial: '%s'\n", + " Serial: '%s'", this->usb_descriptor_.idProduct, this->usb_descriptor_.idVendor, this->string_descriptor_[MANUFACTURER], this->string_descriptor_[PRODUCT], this->string_descriptor_[SERIAL_NUMBER]); + if (this->vbus_monitor_pin_ >= 0) { + ESP_LOGCONFIG(TAG, " VBUS Monitor Pin: GPIO%d", this->vbus_monitor_pin_); + } } } // namespace esphome::tinyusb diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index e85fea9d21a..030356bc03e 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -2,7 +2,9 @@ #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include #include "tinyusb.h" #include "tusb.h" @@ -23,9 +25,17 @@ static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + /// True while a USB host has enumerated and configured the device. + bool is_mounted() const { return tud_mounted(); } + /// Called with the new mount state whenever a host mounts or unmounts the device. + template void add_on_mount_state_callback(F &&callback) { + this->mount_state_callback_.add(std::forward(callback)); + } + void set_usb_desc_product_id(uint16_t product_id) { this->usb_descriptor_.idProduct = product_id; } void set_usb_desc_vendor_id(uint16_t vendor_id) { this->usb_descriptor_.idVendor = vendor_id; } void set_usb_desc_lang_id(uint16_t lang_id) { @@ -37,6 +47,8 @@ class TinyUSB final : public Component { } void set_usb_desc_product(const char *usb_desc_product) { this->string_descriptor_[PRODUCT] = usb_desc_product; } void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } + /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. + void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -50,6 +62,11 @@ class TinyUSB final : public Component { nullptr, // 5: Terminator }; + LazyCallbackManager mount_state_callback_; + // Edge-detection baseline for loop(); is_mounted() reads the live state instead. + bool last_reported_mounted_{false}; + int8_t vbus_monitor_pin_{-1}; + tinyusb_config_t tusb_cfg_{}; tusb_desc_device_t usb_descriptor_{ .bLength = sizeof(tusb_desc_device_t), diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index c0cc6f1d2cb..dfe475f7f2c 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,8 +9,6 @@ from esphome.const import ( CONF_ID, CONF_LEVEL, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@mrtoy-me"] @@ -21,11 +19,6 @@ tm1651_ns = cg.esphome_ns.namespace("tm1651") TM1651Brightness = tm1651_ns.enum("TM1651Brightness") TM1651Display = tm1651_ns.class_("TM1651Display", cg.Component) -SetBrightnessAction = tm1651_ns.class_("SetBrightnessAction", automation.Action) -SetLevelAction = tm1651_ns.class_("SetLevelAction", automation.Action) -SetLevelPercentAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) -TurnOnAction = tm1651_ns.class_("TurnOnAction", automation.Action) -TurnOffAction = tm1651_ns.class_("TurnOffAction", automation.Action) TM1651_BRIGHTNESS_OPTIONS = { 1: TM1651Brightness.TM1651_DARKEST, @@ -66,107 +59,31 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "tm1651.set_brightness", - SetBrightnessAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(validate_brightness), - }, - key=CONF_BRIGHTNESS, +for _name, _key, _validator, _method in ( + ("tm1651.set_brightness", CONF_BRIGHTNESS, validate_brightness, "set_brightness"), + ("tm1651.set_level", CONF_LEVEL, validate_level, "set_level"), + ( + "tm1651.set_level_percent", + CONF_LEVEL_PERCENT, + validate_level_percent, + "set_level_percent", ), - synchronous=True, +): + automation.register_apply_action( + _name, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(TM1651Display), + cv.Required(_key): cv.templatable(_validator), + }, + key=_key, + ), + automation.ApplyField(_key, _method, cg.uint8), + ) + +automation.register_apply_action( + "tm1651.turn_off", BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall("turn_off()") ) -async def tm1651_set_brightness_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]) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) - cg.add(var.set_brightness(template_)) - return var - - -@automation.register_action( - "tm1651.set_level", - SetLevelAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL): cv.templatable(validate_level), - }, - key=CONF_LEVEL, - ), - synchronous=True, +automation.register_apply_action( + "tm1651.turn_on", BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall("turn_on()") ) -async def tm1651_set_level_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]) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) - cg.add(var.set_level(template_)) - return var - - -@automation.register_action( - "tm1651.set_level_percent", - SetLevelPercentAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL_PERCENT): cv.templatable(validate_level_percent), - }, - key=CONF_LEVEL_PERCENT, - ), - synchronous=True, -) -async def tm1651_set_level_percent_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]) - template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) - cg.add(var.set_level_percent(template_)) - return var - - -@automation.register_action( - "tm1651.turn_off", - TurnOffAction, - BINARY_OUTPUT_ACTION_SCHEMA, - synchronous=True, -) -async def output_turn_off_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]) - return var - - -@automation.register_action( - "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_on_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]) - return var diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index 2021f902668..caf526046b8 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -56,44 +55,4 @@ class TM1651Display final : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, brightness) - - void play(const Ts &...x) override { - auto brightness = this->brightness_.value(x...); - this->parent_->set_brightness(brightness); - } -}; - -template class SetLevelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level) - - void play(const Ts &...x) override { - auto level = this->level_.value(x...); - this->parent_->set_level(level); - } -}; - -template class SetLevelPercentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level_percent) - - void play(const Ts &...x) override { - auto level_percent = this->level_percent_.value(x...); - this->parent_->set_level_percent(level_percent); - } -}; - -template class TurnOnAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->turn_on(); } -}; - -template class TurnOffAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->turn_off(); } -}; - } // namespace esphome::tm1651 diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index 3cbda4a57a8..ab510db0c53 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -41,7 +42,6 @@ CONF_LIMIT_LOW = "limit_low" CONF_LIMIT_HIGH = "limit_high" CONF_FAULT_COUNT = "fault_count" CONF_POLARITY = "polarity" -CONF_CONVERSION_RATE = "conversion_rate" CONF_FUNCTION = "function" CONFIG_SCHEMA = ( diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 3b1e7352f98..e5f8544f2fe 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_MODEL from esphome.types import ConfigType @@ -26,5 +26,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).exten async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("toshiba_ac") # used from C++ var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 963114b230c..4e4d403488d 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -145,8 +145,6 @@ float TSL2561Sensor::get_integration_time_ms_() { void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time) { this->integration_time_ = integration_time; } -void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } -void TSL2561Sensor::set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8997d19f53a..0800b87c461 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -51,7 +51,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param gain The new gain. */ - void set_gain(TSL2561Gain gain); + void set_gain(TSL2561Gain gain) { this->gain_ = gain; } /** The "CS" package of this sensor has a slightly different formula for * converting the raw values. Use this setting to indicate that this is a CS @@ -59,7 +59,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param package_cs Is this a CS package. */ - void set_is_cs_package(bool package_cs); + void set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/tsl2591/sensor.py b/esphome/components/tsl2591/sensor.py index fa202b0cea3..ee6ebaeb75c 100644 --- a/esphome/components/tsl2591/sensor.py +++ b/esphome/components/tsl2591/sensor.py @@ -140,30 +140,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_FULL_SPECTRUM in config: - conf = config[CONF_FULL_SPECTRUM] - sens = await sensor.new_sensor(conf) - cg.add(var.set_full_spectrum_sensor(sens)) - - if CONF_INFRARED in config: - conf = config[CONF_INFRARED] - sens = await sensor.new_sensor(conf) - cg.add(var.set_infrared_sensor(sens)) - - if CONF_VISIBLE in config: - conf = config[CONF_VISIBLE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_visible_sensor(sens)) - - if CONF_CALCULATED_LUX in config: - conf = config[CONF_CALCULATED_LUX] - sens = await sensor.new_sensor(conf) - cg.add(var.set_calculated_lux_sensor(sens)) - - if CONF_ACTUAL_GAIN in config: - conf = config[CONF_ACTUAL_GAIN] - sens = await sensor.new_sensor(conf) - cg.add(var.set_actual_gain_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FULL_SPECTRUM, var.set_full_spectrum_sensor) + await sensors(CONF_INFRARED, var.set_infrared_sensor) + await sensors(CONF_VISIBLE, var.set_visible_sensor) + await sensors(CONF_CALCULATED_LUX, var.set_calculated_lux_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 2a5d6a4ee46..d147aae88ab 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -200,8 +200,6 @@ void TSL2591Component::set_infrared_sensor(sensor::Sensor *infrared_sensor) { this->infrared_sensor_ = infrared_sensor; } -void TSL2591Component::set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } - void TSL2591Component::set_full_spectrum_sensor(sensor::Sensor *full_spectrum_sensor) { this->full_spectrum_sensor_ = full_spectrum_sensor; } @@ -218,8 +216,6 @@ void TSL2591Component::set_integration_time(TSL2591IntegrationTime integration_t this->integration_time_ = integration_time; } -void TSL2591Component::set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } - void TSL2591Component::set_device_and_glass_attenuation_factors(float device_factor, float glass_attenuation_factor) { this->device_factor_ = device_factor; this->glass_attenuation_factor_ = glass_attenuation_factor; @@ -242,10 +238,6 @@ void TSL2591Component::set_integration_time_and_gain(TSL2591IntegrationTime inte } } -void TSL2591Component::set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } - -void TSL2591Component::set_name(const char *name) { this->name_ = name; } - bool TSL2591Component::is_adc_valid() { uint8_t status; if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_STATUS, &status)) { diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 3fde3404124..c65fc5f6e54 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -111,13 +111,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { * * @param enable Enable or disable power save mode. */ - void set_power_save_mode(bool enable); + void set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } /** Sets the name for this instance of the device. * * @param name The user-friendly name. */ - void set_name(const char *name); + void set_name(const char *name) { this->name_ = name; } /** Sets the device and glass attenuation factors. * @@ -235,13 +235,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. */ void set_infrared_sensor(sensor::Sensor *infrared_sensor); /** Used by ESPHome framework. */ - void set_visible_sensor(sensor::Sensor *visible_sensor); + void set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } /** Used by ESPHome framework. */ void set_calculated_lux_sensor(sensor::Sensor *calculated_lux_sensor); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ void set_integration_time(TSL2591IntegrationTime integration_time); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ - void set_gain(TSL2591ComponentGain gain); + void set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } /** Used by ESPHome framework. */ void setup() override; /** Used by ESPHome framework. */ diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 9f3f3c13cce..32cbca9a390 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -9,7 +9,7 @@ static const char *const TAG = "tuya.light"; void TuyaLight::setup() { if (this->color_temperature_id_.has_value()) { this->parent_->register_listener(*this->color_temperature_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -27,7 +27,7 @@ void TuyaLight::setup() { } if (this->dimmer_id_.has_value()) { this->parent_->register_listener(*this->dimmer_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -39,7 +39,7 @@ void TuyaLight::setup() { } if (switch_id_.has_value()) { this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -51,7 +51,7 @@ void TuyaLight::setup() { } if (color_id_.has_value()) { this->parent_->register_listener(*this->color_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 82fb96d7879..f9b4fe24532 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -1,10 +1,13 @@ #include "tuya.h" -#include "esphome/components/network/util.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -22,6 +25,14 @@ static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static bool network_is_connected() { +#ifdef USE_NETWORK + return network::is_connected(); +#else + return false; +#endif +} + void Tuya::setup() { this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); if (this->status_pin_ != nullptr) { @@ -554,14 +565,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) { } void Tuya::set_status_pin_() { - bool is_network_ready = network::is_connected() && remote_is_connected(); + bool is_network_ready = network_is_connected() && remote_is_connected(); this->status_pin_->digital_write(is_network_ready); } uint8_t Tuya::get_wifi_status_code_() { uint8_t status = 0x02; - if (network::is_connected()) { + if (network_is_connected()) { status = 0x03; // Protocol version 3 also supports specifying when connected to "the cloud" diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 78633bcf6a8..419659598cd 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -5,6 +5,22 @@ import re from esphome import automation, pins import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + variant_filtered_enum, +) from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -179,6 +195,33 @@ UART_PARITY_OPTIONS = { CONF_FLUSH_TIMEOUT = "flush_timeout" CONF_RX_FULL_THRESHOLD = "rx_full_threshold" CONF_RX_TIMEOUT = "rx_timeout" +CONF_CLOCK_SOURCE = "clock_source" + +UARTClockSource = cg.global_ns.enum("uart_sclk_t") +UART_CLOCK_SOURCES = { + "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, + "APB": UARTClockSource.UART_SCLK_APB, + "XTAL": UARTClockSource.UART_SCLK_XTAL, + "RTC": UARTClockSource.UART_SCLK_RTC, + "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, +} + +# Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. +UART_CLOCK_SOURCES_BY_VARIANT = { + VARIANT_ESP32: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32C2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32C5: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C6: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C61: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H21: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32P4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32S2: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32S3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32S31: ["DEFAULT", "XTAL", "RTC"], +} UARTDirection = uart_ns.enum("UARTDirection") UART_DIRECTIONS = { @@ -263,6 +306,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLUSH_TIMEOUT): cv.All( cv.only_on_esp32, cv.positive_time_period_milliseconds ), + cv.Optional(CONF_CLOCK_SOURCE): cv.All( + cv.only_on_esp32, + variant_filtered_enum(UART_CLOCK_SOURCES_BY_VARIANT, upper=True), + ), cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), cv.Optional(CONF_PARITY, default="NONE"): cv.enum( @@ -344,6 +391,9 @@ async def to_code(config): cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) if CONF_FLUSH_TIMEOUT in config: cg.add(var.set_flush_timeout(config[CONF_FLUSH_TIMEOUT])) + # The member already defaults to UART_SCLK_DEFAULT, so only emit a real choice + if (clock_source := config.get(CONF_CLOCK_SOURCE, "DEFAULT")) != "DEFAULT": + cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 2f8b4dbd117..1d492fce476 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -1,5 +1,6 @@ #ifdef USE_ESP8266 #include "uart_component_esp8266.h" +#include #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -251,23 +252,35 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } } +// A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles +// the bit time then. The whole byte is read inside the ISR, so the clock cannot change partway through. +__attribute__((always_inline)) static inline uint32_t rx_bit_time(uint32_t bit_time) { +#if F_CPU != 160000000L + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- CPU2X is MMIO at a fixed address + return bit_time << (CPU2X & 1); +#else + return bit_time; +#endif +} + void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { - uint32_t wait = arg->bit_time_ + arg->bit_time_ / 3 - 500; + const uint32_t bit_time = rx_bit_time(arg->bit_time_); + uint32_t wait = bit_time + bit_time / 3 - 500; const uint32_t start = arch_get_cpu_cycle_count(); uint8_t rec = 0; // Manually unroll the loop for (int i = 0; i < arg->data_bits_; i++) - rec |= arg->read_bit_(&wait, start) << i; + rec |= arg->read_bit_(&wait, start, bit_time) << i; /* If parity is enabled, just read it and ignore it. */ /* TODO: Should we check parity? Or is it too slow for nothing added..*/ if (arg->parity_ == UART_CONFIG_PARITY_EVEN || arg->parity_ == UART_CONFIG_PARITY_ODD) - arg->read_bit_(&wait, start); + arg->read_bit_(&wait, start, bit_time); // Stop bit - arg->wait_(&wait, start); + arg->wait_(&wait, start, bit_time); if (arg->stop_bits_ == 2) - arg->wait_(&wait, start); + arg->wait_(&wait, start, bit_time); arg->rx_buffer_[arg->rx_in_pos_] = rec; arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; @@ -296,37 +309,39 @@ void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { } { + // Transmit runs from the main loop and never overlaps a CpuFrequencyBoost InterruptLock lock; - uint32_t wait = this->bit_time_; + const uint32_t bit_time = this->bit_time_; + uint32_t wait = bit_time; const uint32_t start = arch_get_cpu_cycle_count(); // Start bit - this->write_bit_(false, &wait, start); + this->write_bit_(false, &wait, start, bit_time); for (int i = 0; i < this->data_bits_; i++) { bool bit = data & (1 << i); - this->write_bit_(bit, &wait, start); + this->write_bit_(bit, &wait, start, bit_time); if (need_parity_bit) parity_bit ^= bit; } if (need_parity_bit) - this->write_bit_(parity_bit, &wait, start); + this->write_bit_(parity_bit, &wait, start, bit_time); // Stop bit - this->write_bit_(true, &wait, start); + this->write_bit_(true, &wait, start, bit_time); if (this->stop_bits_ == 2) - this->wait_(&wait, start); + this->wait_(&wait, start, bit_time); } } -void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start) { +void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { while (arch_get_cpu_cycle_count() - start < *wait) ; - *wait += this->bit_time_; + *wait += bit_time; } -bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start) { - this->wait_(wait, start); +bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { + this->wait_(wait, start, bit_time); return this->rx_pin_.digital_read(); } -void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start) { +void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time) { this->tx_pin_.digital_write(bit); - this->wait_(wait, start); + this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { if (this->rx_in_pos_ == this->rx_out_pos_) diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 469885b6b6c..54bcad39933 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -28,9 +28,9 @@ class ESP8266SoftwareSerial { protected: static void gpio_intr(ESP8266SoftwareSerial *arg); - void wait_(uint32_t *wait, const uint32_t &start); - bool read_bit_(uint32_t *wait, const uint32_t &start); - void write_bit_(bool bit, uint32_t *wait, const uint32_t &start); + void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); uint32_t bit_time_{0}; uint8_t *rx_buffer_{nullptr}; diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index bbeb86bcdb7..a052c6015e7 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,6 +9,7 @@ #include "driver/gpio.h" #include "esp_private/gpio.h" #include "soc/gpio_num.h" +#include "soc/soc_caps.h" #include "soc/uart_pins.h" #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -37,6 +38,32 @@ static constexpr bool is_default_uart0_pin(int8_t pin_num) { return pin_num == U0TXD_GPIO_NUM || pin_num == U0RXD_GPIO_NUM; } +// clock_source_ is stored in a byte; every uart_sclk_t value is a soc_module_clk_t below SOC_MOD_CLK_INVALID +static_assert(SOC_MOD_CLK_INVALID <= UINT8_MAX, "uart_sclk_t no longer fits in uint8_t clock_source_"); + +static const LogString *clock_source_to_str(uart_sclk_t clock_source) { + switch (clock_source) { +#if SOC_UART_SUPPORT_APB_CLK + case UART_SCLK_APB: + return LOG_STR("APB"); +#endif +#if SOC_UART_SUPPORT_XTAL_CLK + case UART_SCLK_XTAL: + return LOG_STR("XTAL"); +#endif +#if SOC_UART_SUPPORT_RTC_CLK + case UART_SCLK_RTC: + return LOG_STR("RTC"); +#endif +#if SOC_UART_SUPPORT_REF_TICK + case UART_SCLK_REF_TICK: + return LOG_STR("REF_TICK"); +#endif + default: + return clock_source == UART_SCLK_DEFAULT ? LOG_STR("DEFAULT") : LOG_STR("UNKNOWN"); + } +} + uart_config_t IDFUARTComponent::get_config_() { uart_parity_t parity = UART_PARITY_DISABLE; if (this->parity_ == UART_CONFIG_PARITY_EVEN) { @@ -70,7 +97,7 @@ uart_config_t IDFUARTComponent::get_config_() { uart_config.parity = parity; uart_config.stop_bits = this->stop_bits_ == 1 ? UART_STOP_BITS_1 : UART_STOP_BITS_2; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; - uart_config.source_clk = UART_SCLK_DEFAULT; + uart_config.source_clk = static_cast(this->clock_source_); uart_config.rx_flow_ctrl_thresh = 122; return uart_config; @@ -160,6 +187,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } + this->last_good_framing_ = this->framing_(); int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; @@ -189,18 +217,9 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - uint32_t invert = 0; - if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { - invert |= UART_SIGNAL_TXD_INV; - } - if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { - invert |= UART_SIGNAL_RXD_INV; - } - if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { - invert |= UART_SIGNAL_RTS_INV; - } - - err = uart_set_line_inverse(this->uart_num_, invert); + // Must precede uart_set_pin() so an inverted TX line never shows the wrong idle + // level; apply_line_settings_() repeats it later for the reset registers. + err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); this->mark_failed(); @@ -214,25 +233,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). - auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + if (this->apply_line_settings_() != ESP_OK) { this->mark_failed(); return; } @@ -250,6 +251,99 @@ void IDFUARTComponent::load_settings(bool dump_config) { } } +uint32_t IDFUARTComponent::line_inversion_mask_() { + uint32_t invert = 0; + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { + invert |= UART_SIGNAL_TXD_INV; + } + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { + invert |= UART_SIGNAL_RXD_INV; + } + if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } + return invert; +} + +esp_err_t IDFUARTComponent::apply_line_settings_() { + // uart_param_config() resets these; call after every use of it. + esp_err_t err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return err; + } + + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). + auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; + err = uart_set_mode(this->uart_num_, mode); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +void IDFUARTComponent::set_framing_(const Framing &framing) { + this->baud_rate_ = framing.baud_rate; + this->data_bits_ = framing.data_bits; + this->stop_bits_ = framing.stop_bits; + this->parity_ = framing.parity; + this->rx_full_threshold_ = framing.rx_full_threshold; +} + +esp_err_t IDFUARTComponent::apply_settings_live() { + if (this->is_failed()) { + return ESP_ERR_INVALID_STATE; + } + // No driver yet: nothing to reconfigure in place. + if (!uart_is_driver_installed(this->uart_num_)) { + this->load_settings(false); + return this->is_failed() ? ESP_FAIL : ESP_OK; + } + // Keeps the driver ring buffers; flushes both hardware FIFOs (in-flight bytes lost). + uart_config_t uart_config = this->get_config_(); + esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + // Failure leaves the registers reset; put back the last accepted framing so the + // getters still describe the hardware. + if (this->last_good_framing_.baud_rate == 0) { + ESP_LOGE(TAG, "uart_param_config (live) failed: %s; no previous framing to restore", esp_err_to_name(err)); + this->mark_failed(); + return err; + } + ESP_LOGW(TAG, "uart_param_config (live) failed: %s; restoring %" PRIu32 " baud", esp_err_to_name(err), + this->last_good_framing_.baud_rate); + this->set_framing_(this->last_good_framing_); + uart_config = this->get_config_(); + esp_err_t restore_err = uart_param_config(this->uart_num_, &uart_config); + if (restore_err != ESP_OK) { + ESP_LOGE(TAG, "UART left unconfigured after failed live reconfigure: %s", esp_err_to_name(restore_err)); + this->mark_failed(); + return err; + } + // Previous framing is live again; report the refusal (line-setting errors log). + this->apply_line_settings_(); + return err; + } + this->last_good_framing_ = this->framing_(); + // The new framing is live; a line-setting failure here only logs. + this->apply_line_settings_(); + return ESP_OK; +} + void IDFUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); LOG_PIN(" TX Pin: ", this->tx_pin_); @@ -269,12 +363,14 @@ void IDFUARTComponent::dump_config() { " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" - " Stop bits: %u" + " Stop bits: %u\n" + " Clock Source: %s" #ifdef USE_UART_WAKE_LOOP_ON_RX "\n Wake on data RX: ENABLED" #endif , - this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_, + LOG_STR_ARG(clock_source_to_str(static_cast(this->clock_source_)))); this->check_logger_conflict(); } diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index a761d80f04a..8684937b030 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -18,6 +18,8 @@ namespace esphome::uart { /// peek byte state (has_peek_/peek_byte_) is not synchronized. class IDFUARTComponent final : public UARTComponent, public Component { public: + // User provided, not "= default": `new(p) IDFUARTComponent()` would zero-fill .bss that is already zero. + IDFUARTComponent() {} void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -35,8 +37,16 @@ class IDFUARTComponent final : public UARTComponent, public Component { void set_flush_timeout(uint32_t flush_timeout_ms) override { this->flush_timeout_ms_ = flush_timeout_ms; } + void set_clock_source(uart_sclk_t clock_source) { this->clock_source_ = static_cast(clock_source); } + uint8_t get_hw_serial_number() { return this->uart_num_; } + /// Discard everything received so far: the peek cache and the driver's RX buffer. + void flush_input() { + this->has_peek_ = false; + uart_flush_input(this->uart_num_); + } + /** * Load the UART with the current settings. * @param dump_config (Optional, default `true`): True for displaying new settings or @@ -52,16 +62,53 @@ class IDFUARTComponent final : public UARTComponent, public Component { void load_settings(bool dump_config) override; using UARTComponent::load_settings; // also bring in the no-arg overload for convenience + /** + * Apply the current framing (baud rate, parity, data/stop bits) to the installed + * driver in place, without the delete/reinstall of load_settings(). Tasks blocked in + * the driver survive and the ring buffers are kept, but both hardware FIFOs are + * flushed: a frame in flight reaches the peer truncated and bytes not yet out of the + * RX FIFO are dropped. No lock is taken: quiesce writers first if that matters. + * rx_full_threshold is not rescaled (call set_rx_full_threshold_ms() first if it + * should follow the baud rate); a rollback restores the value from the last accepted + * configuration, undoing a standalone set_rx_full_threshold() made since. Without an + * installed driver this is a full load_settings(false) instead. + * + * @return ESP_OK once the new framing is live (a line-setting error after that only + * logs). On rejection (unreachable baud rate) the previous framing is restored and + * the driver's error returned; if the restore fails too the component is marked + * failed. ESP_ERR_INVALID_STATE if already failed; ESP_FAIL if the fallback + * load_settings() fails. + */ + esp_err_t apply_settings_live(); + void on_shutdown() override; protected: void check_logger_conflict() override; + uint32_t line_inversion_mask_(); + // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. + esp_err_t apply_line_settings_(); uart_port_t uart_num_{UART_NUM_MAX}; uart_config_t get_config_(); + struct Framing { + uint32_t baud_rate; + uint8_t data_bits; + uint8_t stop_bits; + UARTParityOptions parity; + size_t rx_full_threshold; // sized for the baud rate, so rolled back with it + }; + Framing framing_() const { + return {this->baud_rate_, this->data_bits_, this->stop_bits_, this->parity_, this->rx_full_threshold_}; + } + void set_framing_(const Framing &framing); + // Last framing the driver accepted; baud_rate 0 means none yet. + Framing last_good_framing_{}; + bool has_peek_{false}; - uint8_t peek_byte_; - uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + uint8_t peek_byte_{0}; + uint8_t clock_source_{UART_SCLK_DEFAULT}; ///< uart_sclk_t stored in a byte; the IDF values are all small. + uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). #ifdef USE_UART_WAKE_LOOP_ON_RX // ISR callback for UART RX data notification — wakes the main loop directly. diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py new file mode 100644 index 00000000000..f97a7b2cb3e --- /dev/null +++ b/esphome/components/uart_mux/__init__.py @@ -0,0 +1,82 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.cdc_acm_uart.bridge import CDCACMUARTBridge +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DOMAIN = "uart_mux" +DEPENDENCIES = ["bridge", "uart"] +MULTI_CONF = True + +CONF_BRIDGE_ID = "bridge_id" +CONF_INITIAL_ROUTE = "initial_route" +ROUTE_BRIDGE = "bridge" +ROUTE_LOCAL = "local" + +uart_mux_ns = cg.esphome_ns.namespace("uart_mux") +UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UARTMux), + cv.Required(CONF_BRIDGE_ID): cv.use_id(CDCACMUARTBridge), + cv.Optional(CONF_INITIAL_ROUTE, default=ROUTE_BRIDGE): cv.one_of( + ROUTE_BRIDGE, ROUTE_LOCAL, lower=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Two muxes on one bridge would each believe they own the bus. + owned = fv.full_config.get().data.setdefault(DOMAIN, set()) + bridge_id = str(config[CONF_BRIDGE_ID]) + if bridge_id in owned: + raise cv.Invalid( + f"The bridge '{bridge_id}' is already routed by another 'uart_mux'; " + "each bridge supports one mux.", + [CONF_BRIDGE_ID], + ) + owned.add(bridge_id) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + bridge = await cg.get_variable(config[CONF_BRIDGE_ID]) + var = cg.new_Pvariable(config[CONF_ID], bridge) + await cg.register_component(var, config) + if config[CONF_INITIAL_ROUTE] == ROUTE_LOCAL: + cg.add(var.set_start_local(True)) + + +UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(UARTMux)} +) + + +automation.register_apply_action( + "uart_mux.select_local", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_local()"), +) +automation.register_apply_action( + "uart_mux.select_bridge", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_bridge()"), +) +automation.register_apply_condition( + "uart_mux.is_local", UART_MUX_ACTION_SCHEMA, "is_local()" +) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp new file mode 100644 index 00000000000..953e81d5337 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -0,0 +1,115 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "uart_mux.h" +#include "esphome/core/log.h" + +namespace esphome::uart_mux { + +static const char *const TAG = "uart_mux"; + +void UARTMux::setup() { + // A failed UART never assigned its port; nothing behind the mux can work. + if (this->uart_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->settings_ = { + this->uart_->get_baud_rate(), this->uart_->get_rx_full_threshold(), this->uart_->get_rx_timeout(), + this->uart_->get_rx_buffer_size(), this->uart_->get_data_bits(), this->uart_->get_stop_bits(), + this->uart_->get_parity(), + }; + this->apply_settings_(); + + if (this->start_local_) { + this->select_local(); + } else { + // loop() only completes hand-offs; the bridge keeps the UART until an action. + this->disable_loop(); + } +} + +void UARTMux::loop() { + if (!this->bridge_->is_paused()) { + return; + } + // Bytes that arrived during the hand-off belong to neither owner. + this->uart_->flush_input(); + this->route_ = Route::ROUTE_LOCAL; + ESP_LOGD(TAG, "UART routed to local consumers"); + this->disable_loop(); +} + +void UARTMux::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Mux:\n" + " Start local: %s\n" + " Route: %s", + YESNO(this->start_local_), + this->route_ == Route::ROUTE_LOCAL ? LOG_STR_LITERAL("local") + : this->route_ == Route::ROUTE_PENDING_LOCAL ? LOG_STR_LITERAL("pending local") + : LOG_STR_LITERAL("bridge")); +} + +void UARTMux::load_settings(bool dump_config) { + if (!this->load_settings_warned_) { + this->load_settings_warned_ = true; + ESP_LOGW(TAG, "load_settings() ignored; change the framing on the hardware UART instead"); + } + // Undo whatever the caller set on us. Not re-sampled from the live UART, whose + // fields carry the host's line coding while the bridge owns the bus. + this->apply_settings_(); +} + +void UARTMux::apply_settings_() { + this->baud_rate_ = this->settings_.baud_rate; + this->data_bits_ = this->settings_.data_bits; + this->stop_bits_ = this->settings_.stop_bits; + this->parity_ = this->settings_.parity; + this->rx_full_threshold_ = this->settings_.rx_full_threshold; + this->rx_timeout_ = this->settings_.rx_timeout; + this->rx_buffer_size_ = this->settings_.rx_buffer_size; +} + +void UARTMux::select_local() { + if (this->route_ != Route::ROUTE_BRIDGE) { + return; + } + ESP_LOGD(TAG, "Pausing bridge to route UART locally"); + this->bridge_->pause(); + this->route_ = Route::ROUTE_PENDING_LOCAL; + this->enable_loop(); +} + +void UARTMux::select_bridge() { + if (this->route_ == Route::ROUTE_BRIDGE) { + return; + } + // A bridge that failed setup() has no worker tasks; handing it the bus would kill + // the UART in both directions. + if (this->bridge_->is_failed()) { + ESP_LOGW(TAG, "Bridge failed; keeping the UART routed locally"); + return; + } + // While the pause is still pending the bridge's RX task may be inside + // uart_read_bytes() on this port, and nothing local has run, so flush only a + // completed hand-off. + if (this->route_ == Route::ROUTE_LOCAL) { + this->uart_->flush_input(); + } + this->route_ = Route::ROUTE_BRIDGE; + ESP_LOGD(TAG, "UART routed to bridge"); + this->bridge_->resume(); + this->disable_loop(); +} + +void UARTMux::write_array(const uint8_t *data, size_t len) { + if (!this->is_local()) { + ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); + return; + } + this->uart_->write_array(data, len); +} + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h new file mode 100644 index 00000000000..5b9815a3caf --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.h @@ -0,0 +1,80 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" +#include "esphome/core/component.h" + +namespace esphome::uart_mux { + +/// Shares one hardware UART between a CDC-ACM UART bridge and local consumers. Local +/// consumers bind to the mux as their UART; it forwards to the hardware UART only +/// while routed locally and reports the route through is_connected(). Routing is +/// driven by the select_*() actions, typically from tinyusb's on_mount/on_unmount. +class UARTMux final : public uart::UARTComponent, public Component { + public: + explicit UARTMux(cdc_acm_uart::CDCACMUARTBridge *bridge) : uart_(bridge->get_uart_parent()), bridge_(bridge) {} + + void setup() override; + void loop() override; + void dump_config() override; + // Between the hardware UART (BUS) and its consumers (modbus is BUS - 1): the + // mirrored framing must exist before anything reads it from us. + float get_setup_priority() const override { return setup_priority::BUS - 0.5f; } + + /// Route locally at boot instead of leaving the UART with the bridge. + void set_start_local(bool start_local) { this->start_local_ = start_local; } + + /// Pause the bridge and route the UART to local consumers once it has stopped. + void select_local(); + /// Route the UART back to the bridge. + void select_bridge(); + bool is_local() const { return this->route_ == Route::ROUTE_LOCAL; } + + // uart::UARTComponent: forwarded while routed locally, inert otherwise. + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override { return this->is_local() && this->uart_->peek_byte(data); } + bool read_array(uint8_t *data, size_t len) override { return this->is_local() && this->uart_->read_array(data, len); } + size_t available() override { return this->is_local() ? this->uart_->available() : 0; } + uart::UARTFlushResult flush() override { + return this->is_local() ? this->uart_->flush() : uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + } + bool is_connected() override { return this->is_local(); } + // Ignored: the bridge's tasks block inside the driver, and reinstalling it would + // pull it out from under them. The framing is the hardware UART's to change. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; + + protected: + enum class Route : uint8_t { + ROUTE_BRIDGE, + ROUTE_PENDING_LOCAL, // pause() requested; the bridge may still be on the bus + ROUTE_LOCAL, + }; + + // The hardware UART's settings as configured. Taken once at setup, before the + // bridge can overwrite the live fields with a host's line coding. + struct Settings { + uint32_t baud_rate; + size_t rx_full_threshold; + size_t rx_timeout; + size_t rx_buffer_size; + uint8_t data_bits; + uint8_t stop_bits; + uart::UARTParityOptions parity; + }; + + void check_logger_conflict() override {} + // Publish settings_ through the UARTComponent getters. + void apply_settings_(); + + uart::IDFUARTComponent *uart_; + cdc_acm_uart::CDCACMUARTBridge *bridge_; + Settings settings_{}; + Route route_{Route::ROUTE_BRIDGE}; + bool start_local_{false}; + bool load_settings_warned_{false}; +}; + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index a782d875b9d..d96a731e9c0 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,5 +1,4 @@ -from collections.abc import Callable -from typing import Any, NoReturn +from typing import Any from esphome import automation from esphome.automation import Trigger @@ -48,17 +47,10 @@ UDP_SCHEMA = cv.Schema( ) -def is_relocated(option: str) -> Callable[[Any], NoReturn]: - def validator(value: Any) -> NoReturn: - raise cv.Invalid( - f"The '{option}' option should now be configured in the 'packet_transport' component" - ) - - return validator - - RELOCATED = { - cv.Optional(x): is_relocated(x) + cv.Optional(x): cv.invalid( + f"The '{x}' option should now be configured in the 'packet_transport' component" + ) for x in ( CONF_PROVIDERS, CONF_ENCRYPTION, diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 858516c7462..f662cba4cfb 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -13,7 +13,12 @@ void UDPComponent::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) for (const auto &address : this->addresses_) { struct sockaddr saddr {}; - socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_); + if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { + ESP_LOGW(TAG, "Invalid address %s", address); + // A dropped address silently receives nothing; surface the misconfiguration + this->status_set_warning(LOG_STR("invalid address")); + continue; + } this->sockaddrs_.push_back(saddr); } // set up broadcast socket @@ -94,7 +99,11 @@ void UDPComponent::setup() { // 8266 and RP2040 `Duino for (const auto &address : this->addresses_) { auto ipaddr = IPAddress(); - ipaddr.fromString(address); + if (!ipaddr.fromString(address)) { + ESP_LOGW(TAG, "Invalid address %s", address); + this->status_set_warning(LOG_STR("invalid address")); + continue; + } this->ipaddrs_.push_back(ipaddr); } if (this->should_listen_) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 9d989ad4e67..5bfa959c044 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -14,8 +14,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MILLISIEMENS_PER_CENTIMETER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -29,12 +27,6 @@ UFireECComponent = ufire_ec_ns.class_( "UFireECComponent", cg.PollingComponent, i2c.I2CDevice ) -# Actions -UFireECCalibrateProbeAction = ufire_ec_ns.class_( - "UFireECCalibrateProbeAction", automation.Action -) -UFireECResetAction = ufire_ec_ns.class_("UFireECResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -96,25 +88,14 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ufire_ec.calibrate_probe", - UFireECCalibrateProbeAction, UFIRE_EC_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "calibrate_probe({}, {})", + ((CONF_SOLUTION, cg.float_), (CONF_TEMPERATURE, cg.float_)), + ), ) -async def ufire_ec_calibrate_probe_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) - temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_solution(solution_)) - cg.add(var.set_temperature(temperature_)) - return var UFIRE_EC_RESET_SCHEMA = cv.Schema( @@ -124,17 +105,6 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) -@automation.register_action( - "ufire_ec.reset", - UFireECResetAction, - UFIRE_EC_RESET_SCHEMA, - synchronous=True, +automation.register_apply_action( + "ufire_ec.reset", UFIRE_EC_RESET_SCHEMA, automation.ApplyCall("reset_board()") ) -async def ufire_ec_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index 0928fda9ee2..a7db2b14253 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" @@ -58,28 +57,4 @@ class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction final : public Action { - public: - UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { - this->parent_->calibrate_probe(this->solution_.value(x...), this->temperature_.value(x...)); - } - - protected: - UFireECComponent *parent_; -}; - -template class UFireECResetAction final : public Action { - public: - UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->reset_board(); } - - protected: - UFireECComponent *parent_; -}; - } // namespace esphome::ufire_ec diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index c7e3b6f28d1..5399692e3cc 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -13,8 +13,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -27,15 +25,6 @@ UFireISEComponent = ufire_ise_ns.class_( "UFireISEComponent", cg.PollingComponent, i2c.I2CDevice ) -# Actions -UFireISECalibrateProbeLowAction = ufire_ise_ns.class_( - "UFireISECalibrateProbeLowAction", automation.Action -) -UFireISECalibrateProbeHighAction = ufire_ise_ns.class_( - "UFireISECalibrateProbeHighAction", automation.Action -) -UFireISEResetAction = ufire_ise_ns.class_("UFireISEResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -89,59 +78,20 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "ufire_ise.calibrate_probe_low", - UFireISECalibrateProbeLowAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SOLUTION, "calibrate_probe_low", cg.float_), ) -async def ufire_ise_calibrate_probe_low_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) - cg.add(var.set_solution(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "ufire_ise.calibrate_probe_high", - UFireISECalibrateProbeHighAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SOLUTION, "calibrate_probe_high", cg.float_), ) -async def ufire_ise_calibrate_probe_high_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) - cg.add(var.set_solution(template_)) - return var - UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent)}) - -@automation.register_action( - "ufire_ise.reset", - UFireISEResetAction, - UFIRE_ISE_RESET_SCHEMA, - synchronous=True, +automation.register_apply_action( + "ufire_ise.reset", UFIRE_ISE_RESET_SCHEMA, automation.ApplyCall("reset_board()") ) -async def ufire_ise_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index 85916f227e5..2393524713c 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" @@ -58,36 +57,4 @@ class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction final : public Action { - public: - UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - - void play(const Ts &...x) override { this->parent_->calibrate_probe_low(this->solution_.value(x...)); } - - protected: - UFireISEComponent *parent_; -}; - -template class UFireISECalibrateProbeHighAction final : public Action { - public: - UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - - void play(const Ts &...x) override { this->parent_->calibrate_probe_high(this->solution_.value(x...)); } - - protected: - UFireISEComponent *parent_; -}; - -template class UFireISEResetAction final : public Action { - public: - UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->reset_board(); } - - protected: - UFireISEComponent *parent_; -}; - } // namespace esphome::ufire_ise diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py index 59583357e4f..81d8db9a52a 100644 --- a/esphome/components/ufm01/binary_sensor.py +++ b/esphome/components/ufm01/binary_sensor.py @@ -34,20 +34,14 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + hub = await cg.get_variable(config[CONF_UFM01_ID]) - if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): - sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) - cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) - - if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): - sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) - cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) - - if empty_tube_config := config.get(CONF_EMPTY_TUBE): - sens = await binary_sensor.new_binary_sensor(empty_tube_config) - cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) - - if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): - sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) - cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_UFC_CHIP_ERROR, hub.set_ufc_chip_error_binary_sensor) + await binary_sensors( + CONF_FLOW_DIRECTION_WRONG, hub.set_flow_direction_wrong_binary_sensor + ) + await binary_sensors(CONF_EMPTY_TUBE, hub.set_empty_tube_binary_sensor) + await binary_sensors( + CONF_FLOW_RATE_OUT_OF_RANGE, hub.set_flow_rate_out_of_range_binary_sensor + ) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py index e3281f0b2da..581cdd4a9ba 100644 --- a/esphome/components/ufm01/sensor.py +++ b/esphome/components/ufm01/sensor.py @@ -49,16 +49,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + hub = await cg.get_variable(config[CONF_UFM01_ID]) - if CONF_ACCUMULATED_FLOW in config: - sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) - cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) - - if CONF_FLOW in config: - sens = await sensor.new_sensor(config[CONF_FLOW]) - cg.add(ufm01_component.set_flow_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(ufm01_component.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACCUMULATED_FLOW, hub.set_accumulated_flow_sensor) + await sensors(CONF_FLOW, hub.set_flow_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 880132bad39..fbacd84db0f 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -261,7 +261,7 @@ bool UFM01Component::process_active_stream_() { } if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { - ESP_LOGD(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + ESP_LOGD(TAG, "not start of data at %" PRId32 " (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); this->read_index_ = 0; continue; } diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 5ebe58881d8..1533a893ba6 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,14 +14,14 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -32,16 +32,6 @@ UpdateEntity = update_ns.class_("UpdateEntity", cg.EntityBase) UpdateInfo = update_ns.struct("UpdateInfo") -PerformAction = update_ns.class_( - "PerformAction", automation.Action, cg.Parented.template(UpdateEntity) -) -CheckAction = update_ns.class_( - "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) -) -IsAvailableCondition = update_ns.class_( - "IsAvailableCondition", automation.Condition, cg.Parented.template(UpdateEntity) -) - DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_FIRMWARE, @@ -133,9 +123,8 @@ async def to_code(config: ConfigType) -> None: cg.add_global(update_ns.using) -@automation.register_action( +automation.register_apply_action( "update.perform", - PerformAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), @@ -144,58 +133,21 @@ async def to_code(config: ConfigType) -> None: ): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_FORCE_UPDATE, "perform", cg.bool_), ) -async def update_perform_action_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]) - - force = await cg.templatable(config[CONF_FORCE_UPDATE], args, cg.bool_) - cg.add(var.set_force(force)) - return var -@automation.register_action( - "update.check", - CheckAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(UpdateEntity), - } - ), - synchronous=True, +UPDATE_AUTOMATION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(UpdateEntity), + } ) -async def update_check_action_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]) - return var - -@automation.register_condition( +automation.register_apply_action( + "update.check", UPDATE_AUTOMATION_SCHEMA, automation.ApplyCall("check()") +) +automation.register_apply_condition( "update.is_available", - IsAvailableCondition, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(UpdateEntity), - } - ), + UPDATE_AUTOMATION_SCHEMA, + "state == update::UPDATE_STATE_AVAILABLE", ) -async def update_is_available_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h deleted file mode 100644 index 8ba7b71a9ca..00000000000 --- a/esphome/components/update/automation.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "update_entity.h" - -#include "esphome/core/automation.h" - -namespace esphome::update { - -template class PerformAction final : public Action, public Parented { - TEMPLATABLE_VALUE(bool, force) - - public: - void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } -}; - -template class CheckAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->check(); } -}; - -template class IsAvailableCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } -}; - -} // namespace esphome::update diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index f925d338ff7..96ba6dbd562 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -16,7 +16,7 @@ struct UpdateInfo { std::string firmware_url; std::string md5; bool has_progress{false}; - float progress; + float progress{0}; }; enum UpdateState : uint8_t { diff --git a/esphome/components/uponor_smatrix/sensor/__init__.py b/esphome/components/uponor_smatrix/sensor/__init__.py index 52e755f0054..8167506d783 100644 --- a/esphome/components/uponor_smatrix/sensor/__init__.py +++ b/esphome/components/uponor_smatrix/sensor/__init__.py @@ -67,15 +67,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature_sensor(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - if target_temperature_config := config.get(CONF_TARGET_TEMPERATURE): - sens = await sensor.new_sensor(target_temperature_config) - cg.add(var.set_target_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_TARGET_TEMPERATURE, var.set_target_temperature_sensor) diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index b0b12954b28..92d475e62ec 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -7,6 +7,8 @@ namespace esphome::uptime { class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) UptimeSecondsSensor()` would zero-fill .bss that is already zero. + UptimeSecondsSensor() {} void update() override; void dump_config() override; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index d8eb91586a8..83cb5de89f8 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,15 +7,47 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include +#include #include #include "freertos/ringbuf.h" +#include "esp_err.h" #include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped +// case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker +// tasks drain the same ring buffers. +inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t x_ticks_to_wait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + if (*rx_data_size < out_buf_sz) { + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + } + + return ESP_OK; +} + // Callback types for line coding and line state changes using LineCodingCallback = std::function; using LineStateCallback = std::function; @@ -103,6 +135,8 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented usb_tx_staging_{}; // Non-zero while the TX task holds bytes it has pulled from the ring buffer but not // yet handed to TinyUSB; lets flush() account for data that is in neither the ring // buffer nor TinyUSB's FIFO. diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index e46369660dd..7aa7b46b7b9 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -104,30 +104,6 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); } -static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t x_ticks_to_wait) { - size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); - - if (buf == nullptr) { - return ESP_FAIL; - } - - memcpy(out_buf, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size = read_sz; - - // Buffer's data can be wrapped, in which case we should perform another read - buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); - if (buf != nullptr) { - memcpy(out_buf + *rx_data_size, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size += read_sz; - } - - return ESP_OK; -} - //============================================================================== // USBCDCACMInstance Implementation //============================================================================== @@ -192,7 +168,7 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { } void USBCDCACMInstance::usb_tx_task() { - uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + uint8_t *data = this->usb_tx_staging_.data(); size_t tx_data_size = 0; // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs // immediately (unsigned arithmetic keeps this wrap-safe). diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index edbf75f70f8..5d0f8be1655 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -44,6 +44,7 @@ UART_STOP_BITS_OPTIONS = { } DEFAULT_BAUD_RATE = 9600 +CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" class Type: @@ -56,6 +57,7 @@ class Type: max_channels: int = 1, baud_rate_required: bool = True, max_baud: int = 1_000_000, + has_comm_interface: bool = False, ) -> None: self.name = name cls = cls or name @@ -65,6 +67,9 @@ class Type: self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud + # True for types that claim the CDC comm (interrupt) interface; only these + # accept the claim_comm_interface option. + self.has_comm_interface = has_comm_interface @property def max_channels(self) -> int: @@ -80,11 +85,21 @@ class Type: uart_types = ( - Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), + Type( + "CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False, has_comm_interface=True + ), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type( + "ESP_JTAG", + 0x303A, + 0x1001, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), @@ -95,12 +110,20 @@ uart_types = ( Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), + Type( + "STM32_VCP", + 0x0483, + 0x5740, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), ) def channel_schema(type_: "Type") -> cv.Schema: - return cv.Schema( + schema = cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( cv.ensure_list( @@ -139,9 +162,26 @@ def channel_schema(type_: "Type") -> cv.Schema: max=type_.max_channels, msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", ), - ) + ), } ) + if type_.has_comm_interface: + # The comm (interrupt) interface pins a host hardware channel per device; + # disable to save one on channel-poor hosts (some devices may need it + # claimed before enabling data flow). + schema = schema.extend( + {cv.Optional(CONF_CLAIM_COMM_INTERFACE, default=True): cv.boolean} + ) + else: + schema = schema.extend( + { + cv.Optional(CONF_CLAIM_COMM_INTERFACE): cv.invalid( + f"'{CONF_CLAIM_COMM_INTERFACE}' is only supported on device types " + f"that claim the CDC comm interface; {type_.name} never claims it" + ) + } + ) + return schema CONFIG_SCHEMA = cv.ensure_list( @@ -172,6 +212,9 @@ async def to_code(config: list[ConfigType]) -> None: for device in config: var = await register_usb_client(device) + # The C++ default is true; only emit the override + if not device.get(CONF_CLAIM_COMM_INTERFACE, True): + cg.add(var.set_claim_comm_interface(False)) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 60b7fe4e9c4..3113f695f60 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -431,15 +431,20 @@ void USBUartTypeCdcAcm::on_connected() { // they enable data flow on the bulk endpoints. if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { - auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, - channel->cdc_dev_.interrupt_interface_number, 0); - 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_err_to_name(err_comm)); + if (!this->claim_comm_interface_) { + ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", + channel->cdc_dev_.interrupt_interface_number); } else { - ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.interrupt_interface_claimed = true; + auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, + channel->cdc_dev_.interrupt_interface_number, 0); + 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_err_to_name(err_comm)); + } else { + ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); + channel->cdc_dev_.interrupt_interface_claimed = true; + } } } auto err = diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 9d87bf964c0..22563209da3 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -271,12 +271,16 @@ class USBUartComponent : public usb_host::USBClient { class USBUartTypeCdcAcm : public USBUartComponent { public: USBUartTypeCdcAcm(uint16_t vid, uint16_t pid) : USBUartComponent(vid, pid) {} + void set_claim_comm_interface(bool claim) { this->claim_comm_interface_ = claim; } protected: virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + // Each claimed interface pins one host hardware channel per endpoint; skipping + // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). + bool claim_comm_interface_{true}; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7d98af402dc..7df58c7a2aa 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -21,14 +21,14 @@ from esphome.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_WATER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -43,7 +43,6 @@ DEVICE_CLASSES = [ valve_ns = cg.esphome_ns.namespace("valve") Valve = valve_ns.class_("Valve", cg.EntityBase) -ValveCall = valve_ns.class_("ValveCall") VALVE_OPEN = valve_ns.VALVE_OPEN VALVE_CLOSED = valve_ns.VALVE_CLOSED @@ -63,14 +62,7 @@ VALVE_OPERATIONS = { validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) # Actions -OpenAction = valve_ns.class_("OpenAction", automation.Action) -CloseAction = valve_ns.class_("CloseAction", automation.Action) -StopAction = valve_ns.class_("StopAction", automation.Action) -ToggleAction = valve_ns.class_("ToggleAction", automation.Action) -ControlAction = valve_ns.class_("ControlAction", automation.Action) ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) -ValveIsOpenCondition = valve_ns.class_("ValveIsOpenCondition", Condition) -ValveIsClosedCondition = valve_ns.class_("ValveIsClosedCondition", Condition) # Triggers ValveOpenTrigger = valve_ns.class_("ValveOpenTrigger", automation.Trigger.template()) @@ -184,36 +176,15 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "valve.open", OpenAction, VALVE_ACTION_SCHEMA, synchronous=True -) -async def valve_open_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "valve.close", CloseAction, VALVE_ACTION_SCHEMA, synchronous=True -) -async def valve_close_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "valve.stop", StopAction, VALVE_ACTION_SCHEMA, synchronous=True -) -async def valve_stop_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA, synchronous=True -) -async def valve_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _command in ( + ("valve.open", "set_command_open()"), + ("valve.close", "set_command_close()"), + ("valve.stop", "set_command_stop()"), + ("valve.toggle", "set_command_toggle()"), +): + automation.register_apply_action( + _name, VALVE_ACTION_SCHEMA, automation.ApplyCall(_command), call="make_call" + ) VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( @@ -226,53 +197,16 @@ VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA, synchronous=True +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both dispatch to set_position. +automation.register_apply_action( + "valve.control", + VALVE_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STATE, "set_position", cg.float_), + automation.ApplyField(CONF_POSITION, "set_position", cg.float_), + call="make_call", ) -async def valve_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most - # one is present and both dispatch to set_position. - FIELDS = ( - (CONF_STOP, "set_stop", cg.bool_), - (CONF_STATE, "set_position", cg.float_), - (CONF_POSITION, "set_position", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (ValveCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 63d03a889b3..598bbc1678e 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,92 +6,6 @@ namespace esphome::valve { -template class OpenAction final : public Action { - public: - explicit OpenAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_open().perform(); } - - protected: - Valve *valve_; -}; - -template class CloseAction final : public Action { - public: - explicit CloseAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_close().perform(); } - - protected: - Valve *valve_; -}; - -template class StopAction final : public Action { - public: - explicit StopAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_stop().perform(); } - - protected: - Valve *valve_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_toggle().perform(); } - - protected: - Valve *valve_; -}; - -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `position: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); - ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->valve_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Valve *valve_; - ApplyFn apply_; -}; - -template class ValveIsOpenCondition final : public Condition { - public: - ValveIsOpenCondition(Valve *valve) : valve_(valve) {} - bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } - - protected: - Valve *valve_; -}; - -template class ValveIsClosedCondition final : public Condition { - public: - ValveIsClosedCondition(Valve *valve) : valve_(valve) {} - bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } - - protected: - Valve *valve_; -}; - class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 5c09a025f89..d3b26d64d8b 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -260,154 +260,81 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + binary_sensors = binary_sensor.sub_binary_sensors(config) if config[CONF_MODEL] == CONF_DELTASOL_BS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4221)) cg.add(var.set_dest(0x0010)) - if CONF_RELAY1 in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RELAY1]) - cg.add(var.set_relay1_bsensor(sens)) - if CONF_RELAY2 in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RELAY2]) - cg.add(var.set_relay2_bsensor(sens)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) - if CONF_COLLECTOR_MAX in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_MAX]) - cg.add(var.set_collector_max_bsensor(sens)) - if CONF_COLLECTOR_MIN in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_MIN]) - cg.add(var.set_collector_min_bsensor(sens)) - if CONF_COLLECTOR_FROST in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_FROST]) - cg.add(var.set_collector_frost_bsensor(sens)) - if CONF_TUBE_COLLECTOR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_TUBE_COLLECTOR]) - cg.add(var.set_tube_collector_bsensor(sens)) - if CONF_RECOOLING in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RECOOLING]) - cg.add(var.set_recooling_bsensor(sens)) - if CONF_HQM in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_HQM]) - cg.add(var.set_hqm_bsensor(sens)) + await binary_sensors(CONF_RELAY1, var.set_relay1_bsensor) + await binary_sensors(CONF_RELAY2, var.set_relay2_bsensor) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) + await binary_sensors(CONF_COLLECTOR_MAX, var.set_collector_max_bsensor) + await binary_sensors(CONF_COLLECTOR_MIN, var.set_collector_min_bsensor) + await binary_sensors(CONF_COLLECTOR_FROST, var.set_collector_frost_bsensor) + await binary_sensors(CONF_TUBE_COLLECTOR, var.set_tube_collector_bsensor) + await binary_sensors(CONF_RECOOLING, var.set_recooling_bsensor) + await binary_sensors(CONF_HQM, var.set_hqm_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS_2009: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x427B)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) - if CONF_FROST_PROTECTION_ACTIVE in config: - sens = await binary_sensor.new_binary_sensor( - config[CONF_FROST_PROTECTION_ACTIVE] - ) - cg.add(var.set_frost_protection_active_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) + await binary_sensors( + CONF_FROST_PROTECTION_ACTIVE, var.set_frost_protection_active_bsensor + ) elif config[CONF_MODEL] == CONF_DELTASOL_BS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4278)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_C: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4212)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1121)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS4: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1122)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_CUSTOM: if CONF_COMMAND in config: diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index e8a6ea7bfaa..84e78900900 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -654,269 +654,118 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) if config[CONF_MODEL] == CONF_DELTASOL_BS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4221)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS_2009: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x427B)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4278)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_C: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4212)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1121)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED]) - cg.add(var.set_pump_speed_sensor(sens)) - if CONF_OPERATING_HOURS in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS]) - cg.add(var.set_operating_hours_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) + await sensors(CONF_OPERATING_HOURS, var.set_operating_hours_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS4: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1122)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_TEMPERATURE_5 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) - cg.add(var.set_temperature5_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) - if CONF_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) - cg.add(var.set_flow_rate_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_TEMPERATURE_5, var.set_temperature5_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) + await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_TEMPERATURE_5 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) - cg.add(var.set_temperature5_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) - if CONF_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) - cg.add(var.set_flow_rate_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_TEMPERATURE_5, var.set_temperature5_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) + await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_CUSTOM: if CONF_COMMAND in config: @@ -925,7 +774,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_source(config[CONF_SOURCE])) if CONF_DEST in config: cg.add(var.set_dest(config[CONF_DEST])) - sensors = [] + custom_sensors = [] for conf in config[CONF_SENSORS]: sens = await sensor.new_sensor(conf) lambda_ = await cg.process_lambda( @@ -934,8 +783,8 @@ async def to_code(config: ConfigType) -> None: return_type=cg.float_, ) cg.add(sens.set_message_parser(lambda_)) - sensors.append(sens) - cg.add(var.set_sensors(sensors)) + custom_sensors.append(sens) + cg.add(var.set_sensors(custom_sensors)) vbus = await cg.get_variable(config[CONF_VBUS_ID]) cg.add(vbus.register_listener(var)) diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 4afca4b868f..9ed98fc4dfd 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -2,6 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -31,7 +32,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] -UNIT_COUNTS = "#" ICON_MULTIPLICATION = "mdi:multiplication" ICON_BRIGHTNESS_7 = "mdi:brightness-7" @@ -160,33 +160,14 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if als_cnt_config := config.get(CONF_AMBIENT_LIGHT_COUNTS): - sens = await sensor.new_sensor(als_cnt_config) - cg.add(var.set_ambient_light_counts_sensor(sens)) - - if full_spect_config := config.get(CONF_FULL_SPECTRUM): - sens = await sensor.new_sensor(full_spect_config) - cg.add(var.set_white_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_white_counts_sensor(sens)) - - if infrared_config := config.get(CONF_INFRARED): - sens = await sensor.new_sensor(infrared_config) - cg.add(var.set_infrared_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_AMBIENT_LIGHT_COUNTS, var.set_ambient_light_counts_sensor) + await sensors(CONF_FULL_SPECTRUM, var.set_white_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_white_counts_sensor) + await sensors(CONF_INFRARED, var.set_infrared_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) cg.add(var.set_enable_automatic_mode(config[CONF_AUTO_MODE])) cg.add(var.set_enable_lux_compensation(config[CONF_LUX_COMPENSATION])) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 96f72ad035b..7ff6ac4d352 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,6 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: + // User provided, not "= default": `new(p) VersionTextSensor()` would zero-fill .bss that is already zero. + VersionTextSensor() {} void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d30eaf4768b..14c036de8ee 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import register_action, register_condition import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -14,8 +13,6 @@ from esphome.const import ( CONF_ON_START, CONF_SPEAKER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio", "ring_buffer", "socket"] @@ -62,21 +59,6 @@ MAX_MICROPHONE_SOURCES = 2 voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) -StartAction = voice_assistant_ns.class_( - "StartAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -StartContinuousAction = voice_assistant_ns.class_( - "StartContinuousAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -StopAction = voice_assistant_ns.class_( - "StopAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -IsRunningCondition = voice_assistant_ns.class_( - "IsRunningCondition", automation.Condition, cg.Parented.template(VoiceAssistant) -) -ConnectedCondition = voice_assistant_ns.class_( - "ConnectedCondition", automation.Condition, cg.Parented.template(VoiceAssistant) -) Timer = voice_assistant_ns.struct("Timer") @@ -406,76 +388,41 @@ async def to_code(config: ConfigType) -> None: VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssistant)}) -@register_action( +automation.register_apply_action( "voice_assistant.start_continuous", - StartContinuousAction, VOICE_ASSISTANT_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("request_start(true, true)"), ) -@register_action( +# wake_word defaults to "" so a start without one clears the previous wake word, +# as the old action did. +automation.register_apply_action( "voice_assistant.start", - StartAction, VOICE_ASSISTANT_ACTION_SCHEMA.extend( { cv.Optional(CONF_SILENCE_DETECTION, default=True): cv.boolean, - cv.Optional(CONF_WAKE_WORD): cv.templatable(cv.string), + cv.Optional(CONF_WAKE_WORD, default=""): cv.templatable(cv.string), } ), - synchronous=True, + automation.ApplyField(CONF_WAKE_WORD, "set_wake_word", cg.std_string), + automation.ApplyCall( + "request_start(false, {})", ((CONF_SILENCE_DETECTION, cg.bool_),) + ), ) -async def voice_assistant_listen_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 CONF_SILENCE_DETECTION in config: - cg.add(var.set_silence_detection(config[CONF_SILENCE_DETECTION])) - if wake_word := config.get(CONF_WAKE_WORD): - templ = await cg.templatable(wake_word, args, cg.std_string) - cg.add(var.set_wake_word(templ)) - return var - - -@register_action( - "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "voice_assistant.stop", + VOICE_ASSISTANT_ACTION_SCHEMA, + automation.ApplyCall("request_stop()"), ) -async def voice_assistant_stop_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]) - return var -@register_condition( - "voice_assistant.is_running", IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA +automation.register_apply_condition( + "voice_assistant.is_running", + VOICE_ASSISTANT_ACTION_SCHEMA, + "is_running_or_continuous()", ) -async def voice_assistant_is_running_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - -@register_condition( - "voice_assistant.connected", ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA +automation.register_apply_condition( + "voice_assistant.connected", + VOICE_ASSISTANT_ACTION_SCHEMA, + "get_api_connection() != nullptr", ) -async def voice_assistant_connected_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index d46b089c2ea..e99ee07099f 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -182,6 +182,8 @@ class VoiceAssistant final : public Component { bool is_running() const { return this->state_ != State::IDLE; } void set_continuous(bool continuous) { this->continuous_ = continuous; } bool is_continuous() const { return this->continuous_; } + /// The voice_assistant.is_running condition: a pipeline is running or continuous mode keeps one coming. + bool is_running_or_continuous() const { return this->is_running() || this->is_continuous(); } void set_use_wake_word(bool use_wake_word) { this->use_wake_word_ = use_wake_word; } @@ -347,41 +349,6 @@ class VoiceAssistant final : public Component { #endif }; -template class StartAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, wake_word); - - public: - void play(const Ts &...x) override { - this->parent_->set_wake_word(this->wake_word_.value(x...)); - this->parent_->request_start(false, this->silence_detection_); - } - - void set_silence_detection(bool silence_detection) { this->silence_detection_ = silence_detection; } - - protected: - bool silence_detection_; -}; - -template class StartContinuousAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->request_start(true, true); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->request_stop(); } -}; - -template class IsRunningCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } -}; - -template class ConnectedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } -}; - extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::voice_assistant diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index e46b96c86a8..fd94f56b755 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -34,6 +34,10 @@ void WakeOnLanButton::press_action() { struct sockaddr_storage saddr {}; auto addr_len = socket::set_sockaddr(reinterpret_cast(&saddr), sizeof(saddr), "255.255.255.255", this->port_); + if (addr_len == 0) { + ESP_LOGW(TAG, "Invalid broadcast address"); + return; + } uint8_t buffer[6 + sizeof this->macaddr_ * 16]; memcpy(buffer, PREFIX, sizeof(PREFIX)); for (size_t i = 0; i != 16; i++) { diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 1dc2d008a13..4b65e907de7 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -161,6 +161,7 @@ void WaterHeaterCall::validate_() { } void WaterHeater::publish_state() { + this->set_has_state(true); auto traits = this->get_traits(); ESP_LOGV(TAG, "'%s' >>\n" diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 14ff5ed53cc..93f23424c01 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -2183,8 +2183,6 @@ void GDEW029T5::write_lut_(const uint8_t *lut, const uint8_t size) { this->end_data_(); } -void GDEW029T5::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } - int GDEW029T5::get_width_internal() { return 128; } int GDEW029T5::get_height_internal() { return 296; } void GDEW029T5::dump_config() { @@ -2523,7 +2521,6 @@ void HOT GDEY042T81::display() { ESP_LOGD(TAG, "Set the display back to deep sleep"); this->deep_sleep(); } -void GDEY042T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY042T81::get_width_internal() { return 400; } int GDEY042T81::get_height_internal() { return 300; } uint32_t GDEY042T81::idle_timeout_() { return 5000; } @@ -3156,7 +3153,6 @@ void HOT GDEY0583T81::display() { this->deep_sleep(); } -void GDEY0583T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY0583T81::get_width_internal() { return 648; } int GDEY0583T81::get_height_internal() { return 480; } uint32_t GDEY0583T81::idle_timeout_() { return 5000; } diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.h b/esphome/components/waveshare_epaper/waveshare_epaper.h index fa3737238e6..7e16ce3dc30 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.h +++ b/esphome/components/waveshare_epaper/waveshare_epaper.h @@ -272,7 +272,7 @@ class GDEW029T5 : public WaveshareEPaper { void dump_config() override; void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: void init_display_(); @@ -503,7 +503,7 @@ class GDEY042T81 : public WaveshareEPaper { this->data(0x01); } - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: uint32_t full_update_every_{30}; @@ -695,7 +695,7 @@ class GDEY0583T81 : public WaveshareEPaper { void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: int get_width_internal() override; diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f72..2b00a6f8de1 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -8,6 +8,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import web_server_base +from esphome.components.json import enable_arena from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv @@ -56,9 +57,13 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") -WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) +WebServer = web_server_ns.class_("WebServer", cg.Component) sorting_groups = {} @@ -251,7 +256,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -374,14 +379,18 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) + if CORE.is_esp32: + # The ESP-IDF event source builds state documents in a stack arena + enable_arena() cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +404,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +444,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ec536910e54..74641738ec3 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1,11 +1,12 @@ #include "web_server.h" + +#include #ifdef USE_WEBSERVER #include "esphome/components/json/json_util.h" #include "esphome/core/progmem.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -59,6 +60,12 @@ static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +// Out of line: every GET handler ends with this +[[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { + auto data = builder.serialize(); + request->send(200, ESPHOME_F("application/json"), data.c_str()); +} + // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): // GET /{domain}/{entity_name} - main device state @@ -66,9 +73,12 @@ static const char *const TAG = "web_server"; // GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only) // POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) { + // Every path returns this one object so it is built in place; fields are only set once the URL is known valid + UrlMatch match{}; + // URL must start with '/' and have content after it if (url_len < 2 || url_ptr[0] != '/') - return UrlMatch{}; + return match; const char *p = url_ptr + 1; const char *end = url_ptr + url_len; @@ -90,15 +100,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Must have domain with trailing slash if (!s2) - return UrlMatch{}; - - UrlMatch match{}; - match.domain = make_ref(s1, s2); - match.valid = true; - - if (only_domain || s2 >= end) return match; + if (only_domain || s2 >= end) { + match.domain = make_ref(s1, s2); + match.valid = true; + return match; + } + // Parse remaining segments only when needed const char *s3 = next_segment(s2); const char *s4 = s3 ? next_segment(s3) : nullptr; @@ -109,7 +118,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Reject empty segments if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty())) - return UrlMatch{}; + return match; // Interpret based on segment count if (!s3) { @@ -121,28 +130,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, if (is_post) { match.id = seg2; match.method = seg3; - return match; - } + } else { #ifdef USE_DEVICES - match.device_name = seg2; - match.id = seg3; + match.device_name = seg2; + match.id = seg3; #else - return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES + return match; // 3-segment GET not supported without USE_DEVICES #endif + } } else { // 3 segments after domain: /{domain}/{device}/{entity}/{action} #ifdef USE_DEVICES if (!is_post) { - return UrlMatch{}; // 4-segment GET not supported (action requires POST) + return match; // 4-segment GET not supported (action requires POST) } match.device_name = seg2; match.id = seg3; match.method = seg4; #else - return UrlMatch{}; // Not supported without USE_DEVICES + // Not supported without USE_DEVICES + return match; #endif } + match.domain = make_ref(s1, s2); + match.valid = true; return match; } @@ -189,16 +201,18 @@ DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_gener void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); - auto message = de.message_generator_(web_server_, de.source_); + json::JsonBuilder builder; + de.message_generator_(web_server_, de.source_, builder); + auto message = builder.serialize(); if (this->send(message.c_str(), "state") != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send } else { - // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_() - // The implementations differ due to platform-specific APIs (DISCARDED vs HTTPD_SOCK_ERR_TIMEOUT, close() vs - // fd_.store(0)), but the failure counting and timeout logic should be kept in sync. If you change this logic, - // also update the ESP-IDF implementation. + // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::drain_tail_(). + // The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and + // closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without + // send progress. Keep that stall policy in sync when changing either side. this->consecutive_send_failures_++; if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { // Too many failures, connection is likely dead @@ -246,7 +260,9 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * // deferred queue still not empty which means downstream event queue full, no point trying to send first deq_push_back_with_dedup_(source, message_generator); } else { - auto message = message_generator(web_server_, source); + json::JsonBuilder builder; + message_generator(web_server_, source, builder); + auto message = builder.serialize(); if (this->send(message.c_str(), "state") == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } else { @@ -336,12 +352,8 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} -#ifdef USE_WEBSERVER_CSS_INCLUDE -void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } -#endif -#ifdef USE_WEBSERVER_JS_INCLUDE -void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_include; } -#endif +// Kept out of the callers so the 64 bit division is emitted once +__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; @@ -350,7 +362,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); - root[ESPHOME_F("comment")] = comment_buffer; + root[ESPHOME_F("comment")] = static_cast(comment_buffer); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -358,13 +370,12 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; - root[ESPHOME_F("uptime")] = static_cast(millis_64() / 1000); + root[ESPHOME_F("uptime")] = uptime_seconds(); return builder.serialize(); } void WebServer::setup() { - ControllerRegistry::register_controller(this); this->base_->init(); #ifdef USE_LOGGER @@ -389,7 +400,7 @@ void WebServer::setup() { if (this->events_.empty()) return; char buf[32]; - auto uptime = static_cast(millis_64() / 1000); + auto uptime = uptime_seconds(); size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); @@ -406,10 +417,11 @@ void WebServer::loop() { } #ifdef USE_LOGGER + void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - this->events_.try_send_nodefer(message, message_len, "log", millis()); + this->events_.try_send_nodefer(message, std::min(message_len, LOG_EVENT_MAX_LEN), "log", millis()); } #endif @@ -474,7 +486,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const const size_t scheme_sep = origin.find("://"); if (scheme_sep != std::string::npos) { const std::string host = get_request_header(request, "Host"); - if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here + const size_t authority = scheme_sep + 3; + if (!host.empty() && origin.size() - authority == host.size() && + memcmp(origin.data() + authority, host.data(), host.size()) == 0) return true; } @@ -541,7 +556,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues -static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { +static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); size_t name_len = name.size(); @@ -576,7 +591,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("id")] = id_buf; + root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = prefix; @@ -601,14 +616,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Keep as separate function even though only used once: reduces code size by ~48 bytes // by allowing compiler to share code between template instantiations (bool, float, etc.) template -static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, - JsonDetail start_config) { +static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); root[ESPHOME_F("value")] = value; } template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, +static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; @@ -633,21 +647,21 @@ void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlM // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_STATE); +void WebServer::sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_ALL); +void WebServer::sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); const auto uom_ref = obj->get_unit_of_measurement_ref(); @@ -661,8 +675,6 @@ json::SerializationBuffer<> WebServer::sensor_json_(sensor::Sensor *obj, float v if (!uom_ref.empty()) root[ESPHOME_F("uom")] = uom_ref.c_str(); } - - return builder.serialize(); } #endif @@ -680,32 +692,30 @@ void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->text_sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->text_sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::text_sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->text_sensor_json_((text_sensor::TextSensor *) (source), - ((text_sensor::TextSensor *) (source))->state, DETAIL_STATE); +void WebServer::text_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_sensor_json_((text_sensor::TextSensor *) (source), ((text_sensor::TextSensor *) (source))->state, + DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::text_sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->text_sensor_json_((text_sensor::TextSensor *) (source), - ((text_sensor::TextSensor *) (source))->state, DETAIL_ALL); +void WebServer::text_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_sensor_json_((text_sensor::TextSensor *) (source), ((text_sensor::TextSensor *) (source))->state, + DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "text_sensor", value.c_str(), value.c_str(), start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -741,8 +751,9 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->switch_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->switch_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } @@ -766,14 +777,13 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::switch_state_json_generator(WebServer *web_server, void *source) { - return web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_STATE); +void WebServer::switch_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::switch_all_json_generator(WebServer *web_server, void *source) { - return web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL); +void WebServer::switch_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "switch", value ? "ON" : "OFF", value, start_config); @@ -781,8 +791,6 @@ json::SerializationBuffer<> WebServer::switch_json_(switch_::Switch *obj, bool v root[ESPHOME_F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -794,8 +802,9 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->button_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->button_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("press"))) { DEFER_ACTION(obj, obj->press()); request->send(200); @@ -807,19 +816,16 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::button_all_json_generator(WebServer *web_server, void *source) { - return web_server->button_json_((button::Button *) (source), DETAIL_ALL); +void WebServer::button_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->button_json_((button::Button *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::button_json_(button::Button *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::button_json_(button::Button *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_id(root, obj, "button", start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -837,32 +843,30 @@ void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, con // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->binary_sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->binary_sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::binary_sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), - ((binary_sensor::BinarySensor *) (source))->state, DETAIL_STATE); +void WebServer::binary_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), + ((binary_sensor::BinarySensor *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::binary_sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), - ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL); +void WebServer::binary_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), + ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "binary_sensor", value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -880,8 +884,9 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->fan_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->fan_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("toggle"))) { DEFER_ACTION(obj, obj->toggle().perform()); request->send(200); @@ -921,14 +926,13 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } request->send(404); } -json::SerializationBuffer<> WebServer::fan_state_json_generator(WebServer *web_server, void *source) { - return web_server->fan_json_((fan::Fan *) (source), DETAIL_STATE); +void WebServer::fan_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->fan_json_((fan::Fan *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::fan_all_json_generator(WebServer *web_server, void *source) { - return web_server->fan_json_((fan::Fan *) (source), DETAIL_ALL); +void WebServer::fan_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->fan_json_((fan::Fan *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "fan", obj->state ? "ON" : "OFF", obj->state, start_config); @@ -942,8 +946,6 @@ json::SerializationBuffer<> WebServer::fan_json_(fan::Fan *obj, JsonDetail start if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -961,8 +963,9 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->light_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->light_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("toggle"))) { DEFER_ACTION(obj, obj->toggle().perform()); request->send(200); @@ -1002,17 +1005,16 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa } request->send(404); } -json::SerializationBuffer<> WebServer::light_state_json_generator(WebServer *web_server, void *source) { - return web_server->light_json_((light::LightState *) (source), DETAIL_STATE); +void WebServer::light_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->light_json_((light::LightState *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::light_all_json_generator(WebServer *web_server, void *source) { - return web_server->light_json_((light::LightState *) (source), DETAIL_ALL); +void WebServer::light_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->light_json_((light::LightState *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_value(root, obj, "light", obj->remote_values.is_on() ? "ON" : "OFF", start_config); + set_json_value(root, obj, "light", obj->get_reported_values().is_on() ? "ON" : "OFF", start_config); light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { @@ -1023,8 +1025,6 @@ json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonD } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1042,8 +1042,9 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->cover_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->cover_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -1090,14 +1091,13 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa } request->send(404); } -json::SerializationBuffer<> WebServer::cover_state_json_generator(WebServer *web_server, void *source) { - return web_server->cover_json_((cover::Cover *) (source), DETAIL_STATE); +void WebServer::cover_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->cover_json_((cover::Cover *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::cover_all_json_generator(WebServer *web_server, void *source) { - return web_server->cover_json_((cover::Cover *) (source), DETAIL_ALL); +void WebServer::cover_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->cover_json_((cover::Cover *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, @@ -1112,8 +1112,6 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail root[ESPHOME_F("assumed_state")] = obj->get_traits().get_is_assumed_state(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1131,8 +1129,9 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->number_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->number_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1150,14 +1149,13 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM request->send(404); } -json::SerializationBuffer<> WebServer::number_state_json_generator(WebServer *web_server, void *source) { - return web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE); +void WebServer::number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::number_all_json_generator(WebServer *web_server, void *source) { - return web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL); +void WebServer::number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); const auto uom_ref = obj->get_unit_of_measurement_ref(); @@ -1180,8 +1178,6 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v root[ESPHOME_F("uom")] = uom_ref.c_str(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1198,8 +1194,9 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->date_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->date_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1224,25 +1221,22 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::date_state_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE); +void WebServer::date_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::date_all_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL); +void WebServer::date_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); - set_json_icon_state_value(root, obj, "date", value, value, start_config); + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1259,8 +1253,9 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->time_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->time_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1284,25 +1279,22 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat } request->send(404); } -json::SerializationBuffer<> WebServer::time_state_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE); +void WebServer::time_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::time_all_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL); +void WebServer::time_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: HH:MM:SS (8 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "time", value, value, start_config); + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1319,8 +1311,9 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->datetime_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->datetime_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1344,26 +1337,23 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur } request->send(404); } -json::SerializationBuffer<> WebServer::datetime_state_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE); +void WebServer::datetime_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::datetime_all_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL); +void WebServer::datetime_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null) char value[24]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "datetime", value, value, start_config); + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1381,8 +1371,9 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->text_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->text_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1402,18 +1393,21 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::text_state_json_generator(WebServer *web_server, void *source) { - return web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_STATE); +void WebServer::text_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::text_all_json_generator(WebServer *web_server, void *source) { - return web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL); +void WebServer::text_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); - const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str(); - set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config); + // A password entity shows the mask and prefills the input with nothing, so the secret never + // reaches the JSON and the mask cannot be written back as the value + const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; + set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), + start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); @@ -1421,8 +1415,6 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1440,8 +1432,9 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), detail, builder); + send_json(request, builder); return; } @@ -1461,16 +1454,16 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::select_state_json_generator(WebServer *web_server, void *source) { +void WebServer::select_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *obj = (select::Select *) (source); - return web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_STATE); + web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::select_all_json_generator(WebServer *web_server, void *source) { +void WebServer::select_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *obj = (select::Select *) (source); - return web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_ALL); + web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); // value points to null-terminated string literals from codegen (via current_option()) @@ -1482,8 +1475,6 @@ json::SerializationBuffer<> WebServer::select_json_(select::Select *obj, StringR } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1501,8 +1492,9 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->climate_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->climate_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -1543,17 +1535,16 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url } request->send(404); } -json::SerializationBuffer<> WebServer::climate_state_json_generator(WebServer *web_server, void *source) { +void WebServer::climate_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->climate_json_((climate::Climate *) (source), DETAIL_STATE); + web_server->climate_json_((climate::Climate *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::climate_all_json_generator(WebServer *web_server, void *source) { +void WebServer::climate_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->climate_json_((climate::Climate *) (source), DETAIL_ALL); + web_server->climate_json_((climate::Climate *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config) { +void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, json::JsonBuilder &builder) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - json::JsonBuilder builder; JsonObject root = builder.root(); set_json_id(root, obj, "climate", start_config); const auto traits = obj->get_traits(); @@ -1596,6 +1587,7 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json root[ESPHOME_F("min_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_min_temperature(), target_accuracy), temp_buf); root[ESPHOME_F("step")] = traits.get_visual_target_temperature_step(); + root[ESPHOME_F("temperature_unit")] = static_cast(traits.get_temperature_unit()); this->add_sorting_info_(root, obj); } @@ -1651,7 +1643,6 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json root[ESPHOME_F("state")] = root[ESPHOME_F("target_temperature")]; } - return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif @@ -1688,8 +1679,9 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->lock_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->lock_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } @@ -1713,22 +1705,20 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat } request->send(404); } -json::SerializationBuffer<> WebServer::lock_state_json_generator(WebServer *web_server, void *source) { - return web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_STATE); +void WebServer::lock_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::lock_all_json_generator(WebServer *web_server, void *source) { - return web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_ALL); +void WebServer::lock_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1746,8 +1736,9 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->valve_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->valve_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -1792,14 +1783,13 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa } request->send(404); } -json::SerializationBuffer<> WebServer::valve_state_json_generator(WebServer *web_server, void *source) { - return web_server->valve_json_((valve::Valve *) (source), DETAIL_STATE); +void WebServer::valve_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->valve_json_((valve::Valve *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::valve_all_json_generator(WebServer *web_server, void *source) { - return web_server->valve_json_((valve::Valve *) (source), DETAIL_ALL); +void WebServer::valve_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->valve_json_((valve::Valve *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, @@ -1811,8 +1801,6 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1830,8 +1818,9 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->alarm_control_panel_json_(obj, obj->get_state(), detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->alarm_control_panel_json_(obj, obj->get_state(), detail, builder); + send_json(request, builder); return; } @@ -1873,20 +1862,21 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques } request->send(404); } -json::SerializationBuffer<> WebServer::alarm_control_panel_state_json_generator(WebServer *web_server, void *source) { - return web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), - ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), - DETAIL_STATE); +void WebServer::alarm_control_panel_state_json_generator(WebServer *web_server, void *source, + json::JsonBuilder &builder) { + web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), + ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), + DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::alarm_control_panel_all_json_generator(WebServer *web_server, void *source) { - return web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), - ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), - DETAIL_ALL); +void WebServer::alarm_control_panel_all_json_generator(WebServer *web_server, void *source, + json::JsonBuilder &builder) { + web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), + ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), DETAIL_ALL, + builder); } -json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, - alarm_control_panel::AlarmControlPanelState value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, + alarm_control_panel::AlarmControlPanelState value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "alarm_control_panel", @@ -1894,8 +1884,6 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1913,8 +1901,9 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->water_heater_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->water_heater_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1952,15 +1941,15 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons request->send(404); } -json::SerializationBuffer<> WebServer::water_heater_state_json_generator(WebServer *web_server, void *source) { - return web_server->water_heater_json_(static_cast(source), DETAIL_STATE); +void WebServer::water_heater_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->water_heater_json_(static_cast(source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer *web_server, void *source) { +void WebServer::water_heater_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->water_heater_json_(static_cast(source), DETAIL_ALL); + web_server->water_heater_json_(static_cast(source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); const auto mode = obj->get_mode(); @@ -1977,6 +1966,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); + root[ESPHOME_F("temperature_unit")] = static_cast(traits.get_temperature_unit()); this->add_sorting_info_(root, obj); } @@ -2006,8 +1996,6 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (traits.has_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF)) { root[ESPHOME_F("is_on")] = obj->is_on(); } - - return builder.serialize(); } #endif @@ -2020,8 +2008,9 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->infrared_json_(obj, detail); - request->send(200, ESPHOME_F("application/json"), data.c_str()); + json::JsonBuilder builder; + this->infrared_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("transmit"))) { @@ -2080,13 +2069,12 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur request->send(404); } -json::SerializationBuffer<> WebServer::infrared_all_json_generator(WebServer *web_server, void *source) { +void WebServer::infrared_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->infrared_json_(static_cast(source), DETAIL_ALL); + web_server->infrared_json_(static_cast(source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "infrared", "", 0, start_config); @@ -2099,8 +2087,6 @@ json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, J if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -2113,8 +2099,9 @@ void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, c if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->radio_frequency_json_(obj, detail); - request->send(200, ESPHOME_F("application/json"), data.c_str()); + json::JsonBuilder builder; + this->radio_frequency_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("transmit"))) { @@ -2172,14 +2159,13 @@ void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, c request->send(404); } -json::SerializationBuffer<> WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source) { +void WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->radio_frequency_json_(static_cast(source), DETAIL_ALL); + web_server->radio_frequency_json_(static_cast(source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "radio_frequency", "", 0, start_config); @@ -2197,8 +2183,6 @@ json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::Ra if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -2218,8 +2202,9 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->event_json_(obj, StringRef(), detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->event_json_(obj, StringRef(), detail, builder); + send_json(request, builder); return; } } @@ -2228,17 +2213,17 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa static StringRef get_event_type(event::Event *event) { return event ? event->get_last_event_type() : StringRef(); } -json::SerializationBuffer<> WebServer::event_state_json_generator(WebServer *web_server, void *source) { +void WebServer::event_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *event = static_cast(source); - return web_server->event_json_(event, get_event_type(event), DETAIL_STATE); + web_server->event_json_(event, get_event_type(event), DETAIL_STATE, builder); } // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson -json::SerializationBuffer<> WebServer::event_all_json_generator(WebServer *web_server, void *source) { +void WebServer::event_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *event = static_cast(source); - return web_server->event_json_(event, get_event_type(event), DETAIL_ALL); + web_server->event_json_(event, get_event_type(event), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_id(root, obj, "event", start_config); @@ -2254,8 +2239,6 @@ json::SerializationBuffer<> WebServer::event_json_(event::Event *obj, StringRef root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) #endif @@ -2272,8 +2255,9 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->update_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->update_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -2288,21 +2272,20 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *web_server, void *source) { +void WebServer::update_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE); + web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) { +void WebServer::update_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL); + web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) { +void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - json::JsonBuilder builder; JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version, start_config); + obj->update_info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; @@ -2315,7 +2298,6 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J this->add_sorting_info_(root, obj); } - return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 0fbe4ec5515..143e9a15eae 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -6,8 +6,9 @@ #include "esphome/components/web_server_base/web_server_base.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" +#include "esphome/core/progmem.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -36,12 +37,12 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { -// Type for parameter names that can be stored in flash on ESP8266 -#ifdef USE_ESP8266 -using ParamNameType = const __FlashStringHelper *; -#else -using ParamNameType = const char *; -#endif +// ESP-IDF cuts a log event here: nothing a browser log view needs is longer, and it bounds the +// tail a stalled client keeps. The Arduino backend takes a C string and sends the whole line. +constexpr size_t LOG_EVENT_MAX_LEN = 512; + +// Parameter names live in flash on ESP8266 +using ParamNameType = ProgmemStr; // All platforms need to defer actions to main loop thread. // Multi-core platforms need this for thread safety. @@ -104,7 +105,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE }; can be forgotten. */ #if !defined(USE_ESP32) && defined(USE_ARDUINO) -using message_generator_t = json::SerializationBuffer<>(WebServer *, void *); +using message_generator_t = void(WebServer *, void *, json::JsonBuilder &); class DeferredUpdateEventSourceList; class DeferredUpdateEventSource final : public AsyncEventSource { @@ -190,7 +191,7 @@ class DeferredUpdateEventSourceList final : public std::listcss_url_ = css_url; } /** Set the URL to the script that's embedded in the index page. Defaults to * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ - void set_js_url(const char *js_url); + void set_js_url(const char *js_url) { this->js_url_ = js_url; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE @@ -219,7 +220,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_include Local path to web server script. */ - void set_css_include(const char *css_include); + void set_css_include(const char *css_include) { this->css_include_ = css_include; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE @@ -227,7 +228,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param js_include Local path to web server script. */ - void set_js_include(const char *js_include); + void set_js_include(const char *js_include) { this->js_include_ = js_include; } #endif /** Determine whether internal components should be displayed on the web server. @@ -295,22 +296,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_SENSOR - void on_sensor_update(sensor::Sensor *obj) override; + void on_sensor_update(sensor::Sensor *obj); /// Handle a sensor request under '/sensor/'. void handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> sensor_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> sensor_all_json_generator(WebServer *web_server, void *source); + static void sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_SWITCH - void on_switch_update(switch_::Switch *obj) override; + void on_switch_update(switch_::Switch *obj); /// Handle a switch request under '/switch//'. void handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> switch_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> switch_all_json_generator(WebServer *web_server, void *source); + static void switch_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void switch_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_BUTTON @@ -318,193 +319,198 @@ class WebServer final : public Controller, public Component, public AsyncWebHand void handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match); // Buttons are stateless, so there is no button_state_json_generator - static json::SerializationBuffer<> button_all_json_generator(WebServer *web_server, void *source); + static void button_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_BINARY_SENSOR - void on_binary_sensor_update(binary_sensor::BinarySensor *obj) override; + void on_binary_sensor_update(binary_sensor::BinarySensor *obj); /// Handle a binary sensor request under '/binary_sensor/'. void handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> binary_sensor_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> binary_sensor_all_json_generator(WebServer *web_server, void *source); + static void binary_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void binary_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_FAN - void on_fan_update(fan::Fan *obj) override; + void on_fan_update(fan::Fan *obj); /// Handle a fan request under '/fan//'. void handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> fan_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> fan_all_json_generator(WebServer *web_server, void *source); + static void fan_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void fan_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_LIGHT - void on_light_update(light::LightState *obj) override; + void on_light_update(light::LightState *obj); /// Handle a light request under '/light//'. void handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> light_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> light_all_json_generator(WebServer *web_server, void *source); + static void light_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void light_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_TEXT_SENSOR - void on_text_sensor_update(text_sensor::TextSensor *obj) override; + void on_text_sensor_update(text_sensor::TextSensor *obj); /// Handle a text sensor request under '/text_sensor/'. void handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> text_sensor_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> text_sensor_all_json_generator(WebServer *web_server, void *source); + static void text_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void text_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_COVER - void on_cover_update(cover::Cover *obj) override; + void on_cover_update(cover::Cover *obj); /// Handle a cover request under '/cover//'. void handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> cover_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> cover_all_json_generator(WebServer *web_server, void *source); + static void cover_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void cover_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER - void on_number_update(number::Number *obj) override; + void on_number_update(number::Number *obj); /// Handle a number request under '/number/'. void handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> number_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> number_all_json_generator(WebServer *web_server, void *source); + static void number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE - void on_date_update(datetime::DateEntity *obj) override; + void on_date_update(datetime::DateEntity *obj); /// Handle a date request under '/date/'. void handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> date_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> date_all_json_generator(WebServer *web_server, void *source); + static void date_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void date_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_TIME - void on_time_update(datetime::TimeEntity *obj) override; + void on_time_update(datetime::TimeEntity *obj); /// Handle a time request under '/time/'. void handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> time_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> time_all_json_generator(WebServer *web_server, void *source); + static void time_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void time_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATETIME - void on_datetime_update(datetime::DateTimeEntity *obj) override; + void on_datetime_update(datetime::DateTimeEntity *obj); /// Handle a datetime request under '/datetime/'. void handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> datetime_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> datetime_all_json_generator(WebServer *web_server, void *source); + static void datetime_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void datetime_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_TEXT - void on_text_update(text::Text *obj) override; + void on_text_update(text::Text *obj); /// Handle a text input request under '/text/'. void handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> text_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> text_all_json_generator(WebServer *web_server, void *source); + static void text_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void text_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_SELECT - void on_select_update(select::Select *obj) override; + void on_select_update(select::Select *obj); /// Handle a select request under '/select/'. void handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> select_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> select_all_json_generator(WebServer *web_server, void *source); + static void select_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void select_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_CLIMATE - void on_climate_update(climate::Climate *obj) override; + void on_climate_update(climate::Climate *obj); /// Handle a climate request under '/climate/'. void handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> climate_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> climate_all_json_generator(WebServer *web_server, void *source); + static void climate_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void climate_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_LOCK - void on_lock_update(lock::Lock *obj) override; + void on_lock_update(lock::Lock *obj); /// Handle a lock request under '/lock//'. void handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> lock_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> lock_all_json_generator(WebServer *web_server, void *source); + static void lock_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void lock_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_VALVE - void on_valve_update(valve::Valve *obj) override; + void on_valve_update(valve::Valve *obj); /// Handle a valve request under '/valve//'. void handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> valve_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> valve_all_json_generator(WebServer *web_server, void *source); + static void valve_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void valve_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override; + void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); /// Handle a alarm_control_panel request under '/alarm_control_panel/'. void handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> alarm_control_panel_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> alarm_control_panel_all_json_generator(WebServer *web_server, void *source); + static void alarm_control_panel_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void alarm_control_panel_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_WATER_HEATER - void on_water_heater_update(water_heater::WaterHeater *obj) override; + void on_water_heater_update(water_heater::WaterHeater *obj); /// Handle a water_heater request under '/water_heater//'. void handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> water_heater_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> water_heater_all_json_generator(WebServer *web_server, void *source); + static void water_heater_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void water_heater_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_INFRARED /// Handle an infrared request under '/infrared//transmit'. void handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> infrared_all_json_generator(WebServer *web_server, void *source); + static void infrared_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_RADIO_FREQUENCY /// Handle a radio frequency request under '/radio_frequency//transmit'. void handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source); + static void radio_frequency_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); +#endif + +#ifdef USE_MEDIA_PLAYER + // Not exposed over HTTP; the stub only satisfies ControllerContract + void on_media_player_update(media_player::MediaPlayer *) {} #endif #ifdef USE_EVENT - void on_event(event::Event *obj) override; + void on_event(event::Event *obj); - static json::SerializationBuffer<> event_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> event_all_json_generator(WebServer *web_server, void *source); + static void event_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void event_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); /// Handle a event request under '/event'. void handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match); #endif #ifdef USE_UPDATE - void on_update(update::UpdateEntity *obj) override; + void on_update(update::UpdateEntity *obj); /// Handle a update request under '/update/'. void handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> update_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> update_all_json_generator(WebServer *web_server, void *source); + static void update_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void update_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif /// Override the web handler's canHandle method. @@ -593,7 +599,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand web_server_base::WebServerBase *base_; #ifdef USE_ESP32 - AsyncEventSource events_{"/events", this}; + AsyncEventSource events_{StringRef::from_lit("/events"), this}; #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif @@ -622,77 +628,77 @@ class WebServer final : public Controller, public Component, public AsyncWebHand private: #ifdef USE_SENSOR - json::SerializationBuffer<> sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config); + void sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_SWITCH - json::SerializationBuffer<> switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config); + void switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_BUTTON - json::SerializationBuffer<> button_json_(button::Button *obj, JsonDetail start_config); + void button_json_(button::Button *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_BINARY_SENSOR - json::SerializationBuffer<> binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, - JsonDetail start_config); + void binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config, + json::JsonBuilder &builder); #endif #ifdef USE_FAN - json::SerializationBuffer<> fan_json_(fan::Fan *obj, JsonDetail start_config); + void fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_LIGHT - json::SerializationBuffer<> light_json_(light::LightState *obj, JsonDetail start_config); + void light_json_(light::LightState *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_TEXT_SENSOR - json::SerializationBuffer<> text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, - JsonDetail start_config); + void text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder); #endif #ifdef USE_COVER - json::SerializationBuffer<> cover_json_(cover::Cover *obj, JsonDetail start_config); + void cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER - json::SerializationBuffer<> number_json_(number::Number *obj, float value, JsonDetail start_config); + void number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE - json::SerializationBuffer<> date_json_(datetime::DateEntity *obj, JsonDetail start_config); + void date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_TIME - json::SerializationBuffer<> time_json_(datetime::TimeEntity *obj, JsonDetail start_config); + void time_json_(datetime::TimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATETIME - json::SerializationBuffer<> datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config); + void datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_TEXT - json::SerializationBuffer<> text_json_(text::Text *obj, const std::string &value, JsonDetail start_config); + void text_json_(text::Text *obj, const std::string &value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_SELECT - json::SerializationBuffer<> select_json_(select::Select *obj, StringRef value, JsonDetail start_config); + void select_json_(select::Select *obj, StringRef value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_CLIMATE - json::SerializationBuffer<> climate_json_(climate::Climate *obj, JsonDetail start_config); + void climate_json_(climate::Climate *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_LOCK - json::SerializationBuffer<> lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config); + void lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_VALVE - json::SerializationBuffer<> valve_json_(valve::Valve *obj, JsonDetail start_config); + void valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_ALARM_CONTROL_PANEL - json::SerializationBuffer<> alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, - alarm_control_panel::AlarmControlPanelState value, - JsonDetail start_config); + void alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, + alarm_control_panel::AlarmControlPanelState value, JsonDetail start_config, + json::JsonBuilder &builder); #endif #ifdef USE_EVENT - json::SerializationBuffer<> event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config); + void event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_WATER_HEATER - json::SerializationBuffer<> water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config); + void water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_INFRARED - json::SerializationBuffer<> infrared_json_(infrared::Infrared *obj, JsonDetail start_config); + void infrared_json_(infrared::Infrared *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_RADIO_FREQUENCY - json::SerializationBuffer<> radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config); + void radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_UPDATE - json::SerializationBuffer<> update_json_(update::UpdateEntity *obj, JsonDetail start_config); + void update_json_(update::UpdateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif }; diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 85a4e80541b..08654e353a9 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -69,10 +69,6 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print(""); } -void WebServer::set_css_url(const char *css_url) { this->css_url_ = css_url; } - -void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; } - void WebServer::handle_index_request(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html")); const auto &title = App.get_name(); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f8..72d3bf75b1c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/esphome/components/web_server_idf/sse_chunk.cpp b/esphome/components/web_server_idf/sse_chunk.cpp new file mode 100644 index 00000000000..a312b64c03e --- /dev/null +++ b/esphome/components/web_server_idf/sse_chunk.cpp @@ -0,0 +1,66 @@ +#include "sse_chunk.h" + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::web_server_idf { + +void for_each_chunk_piece(const char *message, size_t message_len, ChunkPieceSink sink, void *ctx) { + if (message == nullptr) { + sink(ctx, CHUNK_END, CHUNK_END_LEN); + return; + } + const char *pos = message; + const char *end = message + message_len; + for (;;) { + const size_t remaining = end - pos; + const auto *n = static_cast(memchr(pos, '\n', remaining)); + // Only a \r before the next \n can end this line, so the search stops there instead of + // rescanning the rest of the message for every line + const auto *r = static_cast(memchr(pos, '\r', n != nullptr ? n - pos : remaining)); + if (n == nullptr && r == nullptr) { + sink(ctx, pos, remaining); + break; + } + const char *brk = (r != nullptr && (n == nullptr || r < n)) ? r : n; + sink(ctx, pos, brk - pos); + pos = brk + ((brk == r && brk + 1 == n) ? 2 : 1); + if (pos >= end) { + break; + } + sink(ctx, SSE_SEP, SSE_SEP_LEN); + } + sink(ctx, SSE_SUFFIX, SSE_SUFFIX_LEN); +} + +size_t build_chunk_prefix(char *buf, size_t size, const char *event, uint32_t id, uint32_t reconnect, bool with_data) { + size_t len = CHUNK_HDR_LEN; + if (reconnect) { + len = buf_append_printf(buf, size, len, "retry: %" PRIu32 "\r\n", reconnect); + } + if (id) { + len = buf_append_printf(buf, size, len, "id: %" PRIu32 "\r\n", id); + } + if (event && *event) { + len = buf_append_str(buf, size, len, "event: "); + len = buf_append_str(buf, size, len, event); + len = buf_append_str(buf, size, len, "\r\n"); + } + if (with_data) { + len = buf_append_str(buf, size, len, "data: "); + } + return len; +} + +void write_chunk_header(char *buf, size_t chunk_len) { + // Eight lowercase hex digits; the temp keeps the terminator format_hex_to writes out of buf + char digits[9]; + format_hex_to(digits, static_cast(chunk_len)); + std::memcpy(buf, digits, 8); + buf[8] = '\r'; + buf[9] = '\n'; +} + +} // namespace esphome::web_server_idf diff --git a/esphome/components/web_server_idf/sse_chunk.h b/esphome/components/web_server_idf/sse_chunk.h new file mode 100644 index 00000000000..c8a4170008a --- /dev/null +++ b/esphome/components/web_server_idf/sse_chunk.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace esphome::web_server_idf { + +// Wire framing of one Server-Sent-Events event inside the chunked /events response: +// "%08x\r\n" + fields + "data: " line ["\r\ndata: " line]... "\r\n" + "\r\n" + "\r\n" +// The chunk header and the final CRLF (the chunk terminator) do not count toward the +// chunk length; everything between them does. + +// HTTP chunk header "%08x\r\n" +constexpr size_t CHUNK_HDR_LEN = 10; +// Chunk terminator, also the only piece of a null message (no data line, no blank line) +constexpr char CHUNK_END[] = "\r\n"; +constexpr size_t CHUNK_END_LEN = sizeof(CHUNK_END) - 1; +// Between two data lines: the end of one and the prefix of the next +constexpr char SSE_SEP[] = "\r\ndata: "; +constexpr size_t SSE_SEP_LEN = sizeof(SSE_SEP) - 1; +// End of the last data line, the blank line ending the event, and the chunk terminator +constexpr char SSE_SUFFIX[] = "\r\n\r\n\r\n"; +constexpr size_t SSE_SUFFIX_LEN = sizeof(SSE_SUFFIX) - 1; + +// Receives one piece of the chunk; ctx is whatever the caller passed to for_each_chunk_piece() +using ChunkPieceSink = void (*)(void *ctx, const char *piece, size_t len); + +// Calls sink for each piece after the prefix: data lines split on \n, \r or \r\n with SSE_SEP +// between them and SSE_SUFFIX after the last (as ESPAsyncWebServer: a trailing break adds no +// empty line, an inner one is kept). A null message yields only the chunk terminator. +void for_each_chunk_piece(const char *message, size_t message_len, ChunkPieceSink sink, void *ctx); + +// Writes the chunk header placeholder, the retry/id/event lines and, with_data, the first +// "data: " into buf. Returns the prefix length; size - 1 or more means the event name did not fit. +size_t build_chunk_prefix(char *buf, size_t size, const char *event, uint32_t id, uint32_t reconnect, bool with_data); + +// Fills the header placeholder at the start of buf once the chunk length is known +void write_chunk_header(char *buf, size_t chunk_len); + +} // namespace esphome::web_server_idf diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9550570cdcf..510b4e50f1a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -1,11 +1,14 @@ #ifdef USE_ESP32 +#include #include #include +#include #include #include #include +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -14,6 +17,7 @@ #include #include "utils.h" +#include "sse_chunk.h" #include "web_server_idf.h" #ifdef USE_WEBSERVER_AUTH_DIGEST @@ -53,6 +57,12 @@ namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf"; +// Only send_json_() may hold the JSON arena: every other frame in this file is capped below one +// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it. +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) +#pragma GCC diagnostic error "-Wstack-usage=2048" +#endif + // Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size. // Buffers of this size must live on the heap - the httpd task stack is too small. static constexpr size_t RECV_CHUNK_SIZE = 1460; @@ -728,7 +738,7 @@ bool AsyncEventSource::loop() { for (size_t i = 0; i < this->sessions_.size();) { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) - if (ses->fd_.load() == 0) { + if (ses->safe_to_delete_()) { // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) @@ -739,7 +749,8 @@ bool AsyncEventSource::loop() { ++i; } } - return !this->sessions_.empty(); + // A session still waiting for httpd to commit its context keeps the loop alive too + return !this->sessions_.empty() || this->has_pending_sessions_.load(std::memory_order_acquire); } void AsyncEventSource::adopt_pending_sessions_main_loop_() { @@ -751,10 +762,18 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { } for (auto *rsp : incoming) { // Already disconnected? Drop it; skip on_connect_/session start on a dead session. - if (rsp->fd_.load() == 0) { + if (rsp->safe_to_delete_()) { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) continue; } + // httpd commits the session context only after the creating handler returns, so stay + // pending until then; httpd_req_cleanup() always commits it or calls destroy() + if (httpd_sess_get_ctx(rsp->hd_, rsp->fd_.load()) != rsp) { + LockGuard guard{this->pending_mutex_}; + this->pending_sessions_.push_back(rsp); + this->has_pending_sessions_.store(true, std::memory_order_release); + continue; + } this->sessions_.push_back(rsp); // Prime first so on_connect_ observes a session that has already sent its // initial ping/config/sorting_groups, matching the pre-refactor ordering. @@ -791,7 +810,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { - // Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup. + // Httpd task only. start_session_main_loop_() sends the greeting and starts the iterator. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -819,9 +838,14 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::start_session_main_loop_() { auto *ws = this->web_server_; - // tcp send buffer is empty on connect, so these should always go through + // The tcp send buffer is empty on connect. A refusal is a closing session or a failed tail + // allocation; nothing retries the greeting, so close and let the client reconnect. auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); + if (!this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000)) { + ESP_LOGW(TAG, "Config not sent to fd %d", this->fd_.load()); + this->request_close_(); + return; + } #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -832,8 +856,13 @@ void AsyncEventSourceResponse::start_session_main_loop_() { message = builder.serialize(); // a (very) large number of these should be able to be queued initially without defer - // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); + // since the only thing in the send buffer at this point is the initial ping/config. + // A refusal means the socket is full or closing; the remaining groups are not sent. The + // session stays up on purpose: partial grouping beats a reconnect loop on a slow link. + if (!this->try_send_nodefer(message.c_str(), message.size(), "sorting_group")) { + ESP_LOGW(TAG, "Sorting groups not sent to fd %d", this->fd_.load()); + break; + } } #endif @@ -865,10 +894,15 @@ void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_g } void AsyncEventSourceResponse::process_deferred_queue_() { + if (this->close_requested_) { + return; + } while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); - auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { + if (this->send_json_(de.source_, de.message_generator_)) { + if (this->close_requested_ || deferred_queue_.empty()) { + return; + } // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -877,204 +911,328 @@ void AsyncEventSourceResponse::process_deferred_queue_() { } } -void AsyncEventSourceResponse::process_buffer_() { - if (event_buffer_.empty()) { +void AsyncEventSourceResponse::request_close_() { + if (!this->close_requested_) { + this->close_requested_ = true; + this->deferred_queue_.clear(); + this->tail_.reset(); + this->tail_cap_ = 0; + this->tail_len_ = 0; + this->tail_sent_ = 0; + this->next_close_attempt_ms_ = App.get_loop_component_start_time(); + } + + this->process_close_(); +} + +void AsyncEventSourceResponse::process_close_() { + if (!this->close_requested_ || this->close_work_queued_.load(std::memory_order_acquire)) { return; } - if (event_bytes_sent_ == event_buffer_.size()) { - event_buffer_.resize(0); - event_bytes_sent_ = 0; + const int fd = this->fd_.load(); + if (fd == 0) { return; } - size_t remaining = event_buffer_.size() - event_bytes_sent_; - int bytes_sent = - httpd_socket_send(this->hd_, this->fd_.load(), event_buffer_.c_str() + event_bytes_sent_, remaining, 0); - if (bytes_sent == HTTPD_SOCK_ERR_TIMEOUT) { - // EAGAIN/EWOULDBLOCK - socket buffer full, try again later - // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_() - // The implementations differ due to platform-specific APIs (HTTPD_SOCK_ERR_TIMEOUT vs DISCARDED, fd_.store(0) vs - // close()), but the failure counting and timeout logic should be kept in sync. If you change this logic, also - // update the Arduino implementation. - this->consecutive_send_failures_++; - if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { - // Too many failures, connection is likely dead - ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu16 " failed sends", - this->consecutive_send_failures_); - this->fd_.store(0); // Mark for cleanup - this->deferred_queue_.clear(); - } - return; - } - if (bytes_sent == HTTPD_SOCK_ERR_FAIL) { - // Real socket error - connection will be closed by httpd and destroy callback will be called - return; - } - if (bytes_sent <= 0) { - // Unexpected error or zero bytes sent - ESP_LOGW(TAG, "Unexpected send result: %d", bytes_sent); + const uint32_t now = App.get_loop_component_start_time(); + if (static_cast(now - this->next_close_attempt_ms_) < 0) { return; } - // Successful send - reset failure counter - this->consecutive_send_failures_ = 0; - event_bytes_sent_ += bytes_sent; - - // Log partial sends for debugging - if (event_bytes_sent_ < event_buffer_.size()) { - ESP_LOGV(TAG, "Partial send: %d/%zu bytes (total: %zu/%zu)", bytes_sent, remaining, event_bytes_sent_, - event_buffer_.size()); + // Queue an identity-checked shutdown on the HTTPD task. The public + // httpd_sess_trigger_close() queues only a reusable fd/session slot and can + // therefore close a new client if the original peer disconnects meanwhile. + this->close_work_queued_.store(true, std::memory_order_release); + const esp_err_t err = httpd_queue_work(this->hd_, &AsyncEventSourceResponse::close_session_work, this); + this->next_close_attempt_ms_ = now + (err == ESP_OK ? CLOSE_CONFIRM_INTERVAL_MS : CLOSE_RETRY_INTERVAL_MS); + if (err == ESP_OK) { + return; } - if (event_bytes_sent_ == event_buffer_.size()) { - event_buffer_.resize(0); - event_bytes_sent_ = 0; + this->close_work_queued_.store(false, std::memory_order_release); + if (!this->close_retry_warning_logged_) { + ESP_LOGW(TAG, "Failed to queue EventSource close (%s); retrying", esp_err_to_name(err)); + this->close_retry_warning_logged_ = true; } } +void AsyncEventSourceResponse::close_session_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + // The HTTPD task remains the session owner. Shutting the socket down makes + // its next select/recv path delete the session and invoke destroy(). + shutdown(fd, SHUT_RDWR); + } + + // Release self only after the HTTPD-task callback has finished every access. + response->close_work_queued_.store(false, std::memory_order_release); +} + +ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { + // httpd frees a session before closing its socket, so the fd may already be a new client's. + // Treated as would-block; the stall timer ends a session that never becomes ours again. + const int fd = this->fd_.load(); + if (httpd_sess_get_ctx(this->hd_, fd) != this) { + return 0; + } + struct msghdr msg {}; + msg.msg_iov = iov; + msg.msg_iovlen = iovcnt; + const ssize_t sent = sendmsg(fd, &msg, MSG_DONTWAIT); + if (sent >= 0) { + return sent; + } + const int err = errno; + if (err == EAGAIN || err == EWOULDBLOCK) { + return 0; + } + ESP_LOGD(TAG, "send error: errno %d", err); + this->request_close_(); + return -1; +} + +void AsyncEventSourceResponse::drain_tail_() { + if (this->sending_ || this->close_requested_ || this->tail_len_ == 0) { + return; + } + SendGuard guard{*this}; + + const size_t remaining = this->tail_len_ - this->tail_sent_; + struct iovec iov = {this->tail_.get() + this->tail_sent_, remaining}; + const ssize_t sent = this->send_(&iov, 1); + if (sent < 0) { + return; + } + if (sent == 0) { + // Socket buffer full, try again later + // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_(). + // The IDF path is intentionally time-based and closes through HTTPD to preserve session ownership. + const uint32_t now = App.get_loop_component_start_time(); + if (this->send_failure_started_ms_ == 0) { + this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall. + } + if (static_cast(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) { + ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu32 " ms without send progress", + now - this->send_failure_started_ms_); + this->request_close_(); + } + return; + } + + this->send_failure_started_ms_ = 0; + this->tail_sent_ += sent; + if (this->tail_sent_ < this->tail_len_) { + ESP_LOGV(TAG, "Partial send: %zd/%zu bytes (total: %u/%u)", sent, remaining, this->tail_sent_, this->tail_len_); + return; + } + // Fully sent; the storage stays for the next stall + this->tail_len_ = 0; +} + +bool AsyncEventSourceResponse::reserve_tail_(size_t len) { + if (this->tail_cap_ >= len) { + return true; + } + if (len > TAIL_MAX_SIZE) { + return false; + } + // Nothing is pending while the tail grows, so free the old block first. PREFER_INTERNAL keeps + // the tail where plain new put it. + this->tail_.reset(); + this->tail_ = RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(len); + this->tail_cap_ = this->tail_ ? len : 0; + return this->tail_cap_ != 0; +} + +bool AsyncEventSourceResponse::stash_chunk_(const char *prefix, size_t prefix_len, const char *message, + size_t message_len, size_t total, size_t sent) { + // A log event of nothing but line breaks is the largest chunk; reserve_tail_() must never + // refuse it for size, only for memory + static_assert(SSE_SEP_LEN * web_server::LOG_EVENT_MAX_LEN + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN <= TAIL_MAX_SIZE, + "the log cut in web_server.h must keep a worst case log event inside the tail ceiling"); + if (!this->reserve_tail_(total)) { + if (sent != 0) { + // Part of the chunk is on the wire, so the stream is broken and the client has to go + ESP_LOGW(TAG, "Cannot buffer a %zu byte chunk, closing", total); + this->request_close_(); + } else { + this->tail_alloc_failed_(total); // nothing on the wire, the caller retries on the stall clock + } + return false; + } + uint8_t *dst = this->tail_.get(); + std::memcpy(dst, prefix, prefix_len); + dst += prefix_len; + for_each_chunk_piece( + message, message_len, + [](void *ctx, const char *piece, size_t len) { + auto &out = *static_cast(ctx); + std::memcpy(out, piece, len); + out += len; + }, + &dst); + this->tail_len_ = total; + this->tail_sent_ = sent; + return true; +} + void AsyncEventSourceResponse::loop() { - process_buffer_(); + if (this->close_requested_) { + this->process_close_(); + return; + } + drain_tail_(); process_deferred_queue_(); + if (this->close_requested_) + return; // One step per loop; refusals retry next pass this->entities_iterator_.try_advance(1); } +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the arena and the JSON buffer +#endif +bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) { + // The arena lives only in this frame, so no call chain ever holds two of them + json::JsonArena arena; + json::JsonBuilder builder(&arena); + generator(this->web_server_, source, builder); + char buf[JSON_BUF_SIZE]; + const size_t len = builder.serialize_to(buf, sizeof(buf)); + if (len < sizeof(buf)) { + return this->try_send_nodefer(buf, len, "state"); + } + + // Too large for the stack: the tail holds the whole chunk and loop() drains it. Serialized + // JSON has no raw line break, so the body is one data line. + if (!this->ready_to_send_()) { + return false; + } + { + SendGuard guard{*this}; + char prefix[PREFIX_BUF_SIZE]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true); + + // Grow the tail until the document fits. Nothing has reached the wire, so a document that + // cannot be held costs only this event, and the tail grown for it is released. + size_t json_len = 0; + size_t cap = std::max(JSON_BUF_SIZE * 2, this->tail_cap_); + for (;;) { + if (!this->reserve_tail_(cap)) { + this->tail_alloc_failed_(cap); + return false; // stays deferred, retried on a later pass + } + const size_t room = cap - prefix_len - SSE_SUFFIX_LEN; + json_len = builder.serialize_to(reinterpret_cast(this->tail_.get()) + prefix_len, room); + if (json_len < room) { + break; + } + if (cap >= TAIL_MAX_SIZE) { + ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room); + this->tail_.reset(); + this->tail_cap_ = 0; + this->send_failure_started_ms_ = 0; + return true; // would never fit, reported as sent + } + cap = std::min(cap * 2, TAIL_MAX_SIZE); + } + + const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN; + write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN); + uint8_t *dst = this->tail_.get(); + std::memcpy(dst, prefix, prefix_len); + std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN); + this->tail_len_ = total; + this->tail_sent_ = 0; + } + drain_tail_(); + return true; +} +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) +#pragma GCC diagnostic pop +#endif + +void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) { + // Same stall clock as a socket that stops draining, so a session cannot retry forever + const uint32_t now = App.get_loop_component_start_time(); + if (this->send_failure_started_ms_ == 0) { + this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall. + ESP_LOGW(TAG, "No memory for a %zu byte chunk", cap); + return; + } + if (static_cast(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) { + ESP_LOGW(TAG, "Closing EventSource after %" PRIu32 " ms without memory", now - this->send_failure_started_ms_); + this->request_close_(); + } +} + +bool AsyncEventSourceResponse::ready_to_send_() { + if (this->sending_ || this->fd_.load() == 0 || this->close_requested_) { + return false; + } + drain_tail_(); + return !this->close_requested_ && this->tail_len_ == 0; +} + bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { - if (this->fd_.load() == 0) { + if (!this->ready_to_send_()) { return false; } + SendGuard guard{*this}; - process_buffer_(); - if (!event_buffer_.empty()) { - // there is still pending event data to send first - return false; + // Everything after the prefix goes out straight from the caller's buffer + char prefix[PREFIX_BUF_SIZE]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), event, id, reconnect, message != nullptr); + if (message == nullptr && prefix_len == CHUNK_HDR_LEN) { + return true; // Match ESPAsyncWebServer: nothing to send } - - // 8 spaces are standing in for the hexidecimal chunk length to print later - const char chunk_len_header[] = " " CRLF_STR; - const int chunk_len_header_len = sizeof(chunk_len_header) - 1; - - event_buffer_.append(chunk_len_header); - - // Use stack buffer for formatting numeric fields to avoid temporary string allocations - // Size: "retry: " (7) + max uint32 (10 digits) + CRLF (2) + null (1) = 20 bytes, use 32 for safety - constexpr size_t num_buf_size = 32; - char num_buf[num_buf_size]; - - if (reconnect) { - int len = snprintf(num_buf, num_buf_size, "retry: %" PRIu32 CRLF_STR, reconnect); - event_buffer_.append(num_buf, len); - } - - if (id) { - int len = snprintf(num_buf, num_buf_size, "id: %" PRIu32 CRLF_STR, id); - event_buffer_.append(num_buf, len); - } - - if (event && *event) { - event_buffer_.append("event: ", sizeof("event: ") - 1); - event_buffer_.append(event); - event_buffer_.append(CRLF_STR, CRLF_LEN); - } - - // Match ESPAsyncWebServer: null message means no data lines and no terminating blank line - if (message) { - // SSE spec requires each line of a multi-line message to have its own "data:" prefix - // Handle \n, \r, and \r\n line endings (matching ESPAsyncWebServer behavior) - - // Fast path: check if message contains any newlines at all - // Most SSE messages (JSON state updates) have no newlines - const char *first_n = static_cast(memchr(message, '\n', message_len)); - const char *first_r = static_cast(memchr(message, '\r', message_len)); - - if (first_n == nullptr && first_r == nullptr) { - // No newlines - fast path (most common case) - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message, message_len); - event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator - } else { - // Has newlines - handle multi-line message - const char *line_start = message; - const char *msg_end = message + message_len; - - // Reuse the first search results - const char *next_n = first_n; - const char *next_r = first_r; - - while (line_start <= msg_end) { - const char *line_end; - const char *next_line; - - if (next_n == nullptr && next_r == nullptr) { - // No more line breaks - output remaining text as final line - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start, msg_end - line_start); - event_buffer_.append(CRLF_STR, CRLF_LEN); - break; - } - - // Determine line ending type and next line start - if (next_n != nullptr && next_r != nullptr) { - if (next_r + 1 == next_n) { - // \r\n sequence - line_end = next_r; - next_line = next_n + 1; - } else { - // Mixed \n and \r - use whichever comes first - line_end = (next_r < next_n) ? next_r : next_n; - next_line = line_end + 1; - } - } else if (next_n != nullptr) { - // Unix LF - line_end = next_n; - next_line = next_n + 1; - } else { - // Old Mac CR - line_end = next_r; - next_line = next_r + 1; - } - - // Output this line - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start, line_end - line_start); - event_buffer_.append(CRLF_STR, CRLF_LEN); - - line_start = next_line; - - // Check if we've consumed all content - if (line_start >= msg_end) { - break; - } - - // Search for next newlines only in remaining string - next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); - next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); - } - - // Terminate message with blank line - event_buffer_.append(CRLF_STR, CRLF_LEN); - } - } - - if (event_buffer_.size() == static_cast(chunk_len_header_len)) { - // Nothing was added, reset buffer - event_buffer_.resize(0); + if (prefix_len >= PREFIX_BUF_SIZE - 1) { + // The appenders truncate silently, which would put a malformed event on the wire + ESP_LOGW(TAG, "Event name too long, dropped"); return true; } - event_buffer_.append(CRLF_STR, CRLF_LEN); + // Gather list: the prefix, then the data lines and their separators from the message + struct Gather { + struct iovec iov[MAX_SEND_IOV]; // left uninitialized on purpose + int iovcnt{1}; + size_t total{0}; + bool fits{true}; + } g; + g.total = prefix_len; + for_each_chunk_piece( + message, message_len, + [](void *ctx, const char *piece, size_t len) { + auto &g = *static_cast(ctx); + g.total += len; + if (len == 0) { + return; + } + if (g.iovcnt == MAX_SEND_IOV) { + g.fits = false; + return; + } + g.iov[g.iovcnt++] = {const_cast(piece), len}; + }, + &g); + // The header and the terminator are not part of the chunk length + write_chunk_header(prefix, g.total - CHUNK_HDR_LEN - CHUNK_END_LEN); + g.iov[0] = {prefix, prefix_len}; - // chunk length header itself and the final chunk terminating CRLF are not counted as part of the chunk - int chunk_len = event_buffer_.size() - CRLF_LEN - chunk_len_header_len; - char chunk_len_str[9]; - snprintf(chunk_len_str, 9, "%08x", chunk_len); - std::memcpy(&event_buffer_[0], chunk_len_str, 8); - - event_bytes_sent_ = 0; - process_buffer_(); - - return true; + // A message with more lines than the list holds skips straight to the tail + const ssize_t sent = g.fits ? this->send_(g.iov, g.iovcnt) : 0; + if (sent < 0) { + return false; + } + if (static_cast(sent) == g.total) { + this->send_failure_started_ms_ = 0; // progress, whichever stall clock was running + return true; + } + // The caller's buffers do not outlive this call, so keep the chunk and continue from loop() + return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent); } void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *event_type, @@ -1095,16 +1253,20 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e ESP_LOGE(TAG, "Can't defer non-state event"); } - process_buffer_(); + drain_tail_(); process_deferred_queue_(); - if (!event_buffer_.empty() || !deferred_queue_.empty()) { + if (this->close_requested_) { + return; + } + + if (this->tail_len_ != 0 || !deferred_queue_.empty()) { // outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point // trying to send first deq_push_back_with_dedup_(source, message_generator); } else { - auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { + // A send error closes the session and clears the queue; nothing is queued after that + if (!this->send_json_(source, message_generator) && !this->close_requested_) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 6469b4c5648..2b62d68790e 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -18,8 +18,11 @@ #ifdef USE_WEBSERVER #include "esphome/components/json/json_util.h" #include "esphome/components/web_server/list_entities.h" +#include "sse_chunk.h" #endif +struct iovec; // NOLINT(readability-identifier-naming) - forward decl of lwip's gather list entry + namespace esphome { #ifdef USE_WEBSERVER namespace web_server { @@ -255,7 +258,7 @@ class AsyncWebHandler { class AsyncEventSource; class AsyncEventSourceResponse; -using message_generator_t = json::SerializationBuffer<>(esphome::web_server::WebServer *, void *); +using message_generator_t = void(esphome::web_server::WebServer *, void *, json::JsonBuilder &); /* This class holds a pointer to the source component that wants to publish a state event, and a pointer to a function @@ -300,7 +303,37 @@ class AsyncEventSourceResponse { void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); - void process_buffer_(); + // A new chunk may go out: not re-entered from a log line, session alive, tail empty + bool ready_to_send_(); + // Non-blocking gather write. Returns bytes written, 0 on would-block or a socket that is not + // ours, -1 after requesting the close on any other error. + ssize_t send_(struct iovec *iov, int iovcnt); + // Push what is left of the chunk in tail_ to the socket; owns the stall timer. + void drain_tail_(); + // Grow tail_ to hold len bytes, kept at its high-water mark. False on OOM. + bool reserve_tail_(size_t len); + // Keep the whole chunk in tail_ and continue from sent; false when the tail cannot be allocated. + bool stash_chunk_(const char *prefix, size_t prefix_len, const char *message, size_t message_len, size_t total, + size_t sent); + // Send a state event; JSON too large for the stack buffer is serialized into tail_ instead + bool send_json_(void *source, message_generator_t *generator); + // Warn once, and close the session once the stall timeout passes with no memory for the tail + void tail_alloc_failed_(size_t cap); + void request_close_(); + + // A log line emitted inside a send re-enters try_send_nodefer on this session; refuse it + struct SendGuard { + AsyncEventSourceResponse &owner; + explicit SendGuard(AsyncEventSourceResponse &owner) : owner(owner) { owner.sending_ = true; } + ~SendGuard() { this->owner.sending_ = false; } + }; + void process_close_(); + static void close_session_work(void *arg); + + // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + bool safe_to_delete_() const { + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + } static void destroy(void *p); AsyncEventSource *server_; @@ -309,10 +342,37 @@ class AsyncEventSourceResponse { std::vector deferred_queue_; esphome::web_server::WebServer *web_server_; esphome::web_server::ListEntitiesIterator entities_iterator_; - std::string event_buffer_; - size_t event_bytes_sent_; - uint16_t consecutive_send_failures_{0}; - static constexpr uint16_t MAX_CONSECUTIVE_SEND_FAILURES = 2500; // ~20 seconds at 125Hz loop rate + // One chunk the socket did not take whole, allocated on the first stall; the only heap use + // on the send path + RAMUniquePtr tail_; + uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. + uint32_t next_close_attempt_ms_{0}; + uint16_t tail_cap_{0}; + uint16_t tail_len_{0}; // Zero means nothing pending + uint16_t tail_sent_{0}; + // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. + std::atomic close_work_queued_{false}; + // Main-loop only; the HTTPD task never reads or writes these flags. + bool close_requested_{false}; + bool close_retry_warning_logged_{false}; + bool sending_{false}; + // The longest multi line log message in the tree (a climate dump_config) has 22 lines; a + // longer one goes through the tail + static constexpr size_t MAX_SEND_LINES = 22; + static constexpr size_t MAX_SEND_IOV = 1 + 2 * MAX_SEND_LINES; + // Chunk header, retry/id/event lines and the first "data: " + static constexpr size_t PREFIX_BUF_SIZE = 128; + + // Stack buffer for a state event's JSON; a larger document is serialized into the tail + static constexpr size_t JSON_BUF_SIZE = 1024; + // Same ceiling JsonBuilder::serialize() applies (max_heap_size in json_util.cpp); a larger + // document is dropped before anything is on the wire + static constexpr size_t JSON_MAX_SIZE = 5120; + // Most RAM a stalled session keeps: the largest state document plus any accepted framing + static constexpr size_t TAIL_MAX_SIZE = JSON_MAX_SIZE + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN; + static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; + static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; + static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; @@ -322,7 +382,7 @@ class AsyncEventSource : public AsyncWebHandler { using connect_handler_t = std::function; public: - AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {} + AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) @@ -352,7 +412,7 @@ class AsyncEventSource : public AsyncWebHandler { // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); - std::string url_; + StringRef url_; // Must outlive this object (string literal) // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1691dcc2935..1ba89468413 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_mbedtls_tls_extras, ) from esphome.components.network import ( add_use_address, @@ -66,14 +67,13 @@ from esphome.const import ( ) from esphome.core import ( CORE, - ID, CoroPriority, EsphomeError, HexInt, coroutine_with_priority, ) import esphome.final_validate as fv -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import wpa2_eap @@ -168,6 +168,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -287,7 +290,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -349,7 +354,7 @@ def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config full_config = fv.full_config.get() - has_improv = "esp32_improv" in full_config + has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config @@ -486,12 +491,14 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -601,7 +608,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -625,6 +633,9 @@ async def to_code(config): networks = config.get(CONF_NETWORKS, []) if networks: cg.add(var.init_sta(len(networks))) + if len(networks) > 1: + # The ESP32 scan can filter one SSID in the driver; with several the whole list is kept + cg.add_define("USE_WIFI_MULTI_SSID") def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) @@ -647,7 +658,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -658,15 +671,38 @@ async def to_code(config): # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) + if has_eap: + # wpa_supplicant's EAP client negotiates with whatever the RADIUS + # server offers, and a failed handshake leaves the device off the + # network, so keep every mbedTLS client feature the esp32 platform + # would otherwise trim. + require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") @@ -735,7 +771,7 @@ async def to_code(config): "Applying high-performance WiFi settings (PSRAM guaranteed): 512 RX buffers, 32 TX buffers" ) # PSRAM is guaranteed - use aggressive settings - # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE is set to true in networking component + # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE may be set to true in networking component # Based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702 # Large dynamic RX buffers (requires PSRAM) @@ -793,49 +829,55 @@ async def to_code(config): CORE.add_job(final_step) -@automation.register_condition("wifi.connected", WiFiConnectedCondition, cv.Schema({})) -async def wifi_connected_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.enabled", WiFiEnabledCondition, cv.Schema({})) -async def wifi_enabled_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.ap_active", WiFiAPActiveCondition, cv.Schema({})) -async def wifi_ap_active_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_action( - "wifi.enable", WiFiEnableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.connected", + WiFiConnectedCondition, + cv.Schema({}), ) -async def wifi_enable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.disable", WiFiDisableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.enabled", + WiFiEnabledCondition, + cv.Schema({}), ) -async def wifi_disable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.ap_active", + WiFiAPActiveCondition, + cv.Schema({}), +) + + +automation.register_bare_action( + "wifi.enable", + WiFiEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.disable", + WiFiDisableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_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" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -868,6 +910,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f9e80995e1b..125139ad163 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,8 +37,8 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_IMPROV -#include "esphome/components/esp32_improv/esp32_improv_component.h" +#ifdef USE_IMPROV_BLE +#include "esphome/components/improv_ble/improv_ble_component.h" #endif #ifdef USE_IMPROV_SERIAL @@ -226,7 +226,7 @@ bool CompactString::operator==(const StringRef &other) const { /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ /// ├──────────────────────────────────────────────────────────────────────┤ -/// │ When captive_portal or esp32_improv is active, WiFi scanning is │ +/// │ When captive_portal or improv_ble is active, WiFi scanning is │ /// │ disabled because it disrupts AP clients (radio leaves AP channel │ /// │ to hop through other channels, causing client disconnections). │ /// │ │ @@ -478,9 +478,9 @@ bool WiFiComponent::needs_full_scan_results_() const { } #endif -#ifdef USE_IMPROV +#ifdef USE_IMPROV_BLE // BLE improv also needs results during provisioning - if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) { + if (improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active()) { return true; } #endif @@ -746,10 +746,10 @@ void WiFiComponent::start() { #endif #endif // USE_WIFI_AP } -#ifdef USE_IMPROV - if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { +#ifdef USE_IMPROV_BLE + if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } #endif this->wifi_apply_hostname_(); @@ -805,7 +805,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -894,12 +894,12 @@ void WiFiComponent::loop() { } #endif // USE_WIFI_AP -#ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && - !esp32_improv::global_improv_component->should_start()) { - if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { +#ifdef USE_IMPROV_BLE + if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() && + !improv_ble::global_improv_component->should_start()) { + if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } } @@ -1499,8 +1499,8 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->has_completed_scan_after_captive_portal_start_ = - true; // Track that we've done a scan since captive portal started + // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan + this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_(); this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { @@ -1644,9 +1644,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } -#ifdef USE_IMPROV - if (this->is_esp32_improv_active_()) { - esp32_improv::global_improv_component->stop(); +#ifdef USE_IMPROV_BLE + if (this->is_improv_ble_active_()) { + improv_ble::global_improv_component->stop(); } #endif @@ -1878,7 +1878,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_esp32_improv_active_()) { + } else if (this->is_improv_ble_active_()) { // Improv doesn't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1969,7 +1969,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if captive portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2228,9 +2228,9 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } -bool WiFiComponent::is_esp32_improv_active_() { -#ifdef USE_IMPROV - return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); +bool WiFiComponent::is_improv_ble_active_() { +#ifdef USE_IMPROV_BLE + return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); #else return false; #endif @@ -2416,7 +2416,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); -#if defined(USE_RP2) || defined(USE_ESP32) +#if defined(USE_RP2) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 94fdd9bc142..67913796499 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -178,12 +178,12 @@ struct EAPAuth { using bssid_t = std::array; -/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID) -static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; +// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of +// its BSSIDs are kept, the strongest ones +static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12; -// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) -// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2) || defined(USE_ESP32) +// RP2040's callback delivers results one at a time with no count, so it needs a growable vector +#if defined(USE_RP2) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -797,7 +797,7 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); - bool is_esp32_improv_active_(); + bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); @@ -919,11 +919,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers @@ -954,6 +954,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID) + bool scan_driver_filtered_{false}; + bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; } +#else + constexpr bool is_scan_driver_filtered_() const { return false; } +#endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) // Platform-specific STA state enum, defined in platform cpp file. // On ESP8266, written from SDK system context (wifi_event_callback) — diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 031da1b355f..60ec3f9a4d5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + this->scan_done_ = true; + return; + } // Second pass: store matching networks for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ce75d213301..24bf64a99ce 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); uint16_t number = it.number; - bool needs_full = this->needs_full_scan_results_(); + const bool filtered = this->is_scan_driver_filtered_(); + const bool needs_full = this->needs_full_scan_results_(); { // Mutate in place under the lock; blocking a portal request is fine and // avoids scratch buffers @@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } - // Smart reserve: full capacity if needed, small reserve otherwise - this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); + const size_t wanted = filtered ? std::min(number, WIFI_SCAN_RESULT_BOUND) : number; + // Storage is reused across the scans of one retry cycle and freed on connect; an exhausted + // heap drops this scan and the retry logic scans again + if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "No memory for %zu scan results", wanted); + return; + } #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor @@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks const char *ssid_cstr = reinterpret_cast(record.ssid); - - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (this->scan_result_.size() < wanted) { this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; } + // Records arrive in scan order, not by signal, so a bounded store keeps the strongest by + // replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained + // network hidden behind 12 stronger APs of its own SSID is not a real deployment + WiFiScanResult *weakest = &this->scan_result_[0]; + for (auto &res : this->scan_result_) { + if (res.get_rssi() < weakest->get_rssi()) + weakest = &res; + } + if (record.rssi <= weakest->get_rssi()) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + // Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash + weakest->~WiFiScanResult(); + new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), - needs_full ? "" : " (filtered)"); + filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL("")); #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS this->notify_scan_results_listeners_(); #endif @@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { wifi_scan_config_t config{}; config.ssid = nullptr; config.bssid = nullptr; +#ifndef USE_WIFI_MULTI_SSID + // One configured network with an SSID: let the driver keep only its APs, so the WiFi library + // holds fewer records during the scan. Full results (portal, provisioning, listeners) and a + // network configured by BSSID alone still scan everything + this->scan_driver_filtered_ = + !this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty(); + if (this->scan_driver_filtered_) { + config.ssid = const_cast(reinterpret_cast(this->sta_[0].get_ssid().c_str())); + } +#endif config.channel = 0; config.show_hidden = true; config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 63a63e7342a..940f2a07830 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + WiFi.scanDelete(); + return; + } // Second pass: store matching networks for (int i = 0; i < num; i++) { diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ea9e5a3b0c9..ac62812fcf9 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -41,16 +41,6 @@ _WG_KEY_REGEX = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") wireguard_ns = cg.esphome_ns.namespace("wireguard") Wireguard = wireguard_ns.class_("Wireguard", cg.Component, cg.PollingComponent) AllowedIP = wireguard_ns.struct("AllowedIP") -WireguardPeerOnlineCondition = wireguard_ns.class_( - "WireguardPeerOnlineCondition", automation.Condition -) -WireguardEnabledCondition = wireguard_ns.class_( - "WireguardEnabledCondition", automation.Condition -) -WireguardEnableAction = wireguard_ns.class_("WireguardEnableAction", automation.Action) -WireguardDisableAction = wireguard_ns.class_( - "WireguardDisableAction", automation.Action -) def _wireguard_key(value): @@ -163,47 +153,17 @@ async def to_code(config): await cg.register_component(var, config) -@automation.register_condition( - "wireguard.peer_online", - WireguardPeerOnlineCondition, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), +WIREGUARD_AUTOMATION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}) + +automation.register_apply_condition( + "wireguard.peer_online", WIREGUARD_AUTOMATION_SCHEMA, "is_peer_up()" ) -async def wireguard_peer_up_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_condition( - "wireguard.enabled", - WireguardEnabledCondition, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), +automation.register_apply_condition( + "wireguard.enabled", WIREGUARD_AUTOMATION_SCHEMA, "is_enabled()" ) -async def wireguard_enabled_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "wireguard.enable", - WireguardEnableAction, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), - synchronous=True, +automation.register_apply_action( + "wireguard.enable", WIREGUARD_AUTOMATION_SCHEMA, automation.ApplyCall("enable()") ) -async def wireguard_enable_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "wireguard.disable", - WireguardDisableAction, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), - synchronous=True, +automation.register_apply_action( + "wireguard.disable", WIREGUARD_AUTOMATION_SCHEMA, automation.ApplyCall("disable()") ) -async def wireguard_disable_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/wireguard/binary_sensor.py b/esphome/components/wireguard/binary_sensor.py index 2ba59d4c39d..a1fd366ccf6 100644 --- a/esphome/components/wireguard/binary_sensor.py +++ b/esphome/components/wireguard/binary_sensor.py @@ -24,12 +24,8 @@ CONFIG_SCHEMA = { async def to_code(config): - parent = await cg.get_variable(config[CONF_WIREGUARD_ID]) + hub = await cg.get_variable(config[CONF_WIREGUARD_ID]) - if status_config := config.get(CONF_STATUS): - sens = await binary_sensor.new_binary_sensor(status_config) - cg.add(parent.set_status_sensor(sens)) - - if enabled_config := config.get(CONF_ENABLED): - sens = await binary_sensor.new_binary_sensor(enabled_config) - cg.add(parent.set_enabled_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_STATUS, hub.set_status_sensor) + await binary_sensors(CONF_ENABLED, hub.set_enabled_sensor) diff --git a/esphome/components/wireguard/sensor.py b/esphome/components/wireguard/sensor.py index 4c9d601eee2..49146025afa 100644 --- a/esphome/components/wireguard/sensor.py +++ b/esphome/components/wireguard/sensor.py @@ -19,8 +19,7 @@ CONFIG_SCHEMA = { async def to_code(config): - parent = await cg.get_variable(config[CONF_WIREGUARD_ID]) + hub = await cg.get_variable(config[CONF_WIREGUARD_ID]) - if latest_handshake_config := config.get(CONF_LATEST_HANDSHAKE): - sens = await sensor.new_sensor(latest_handshake_config) - cg.add(parent.set_handshake_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_LATEST_HANDSHAKE, hub.set_handshake_sensor) diff --git a/esphome/components/wireguard/text_sensor.py b/esphome/components/wireguard/text_sensor.py index e48678f5bf4..7beb4b08f06 100644 --- a/esphome/components/wireguard/text_sensor.py +++ b/esphome/components/wireguard/text_sensor.py @@ -16,8 +16,7 @@ CONFIG_SCHEMA = { async def to_code(config): - parent = await cg.get_variable(config[CONF_WIREGUARD_ID]) + hub = await cg.get_variable(config[CONF_WIREGUARD_ID]) - if address_config := config.get(CONF_ADDRESS): - sens = await text_sensor.new_text_sensor(address_config) - cg.add(parent.set_address_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_ADDRESS, hub.set_address_sensor) diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c9c2feb7aef..68885f3a351 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -4,7 +4,6 @@ #include #include -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/time/real_time_clock.h" @@ -164,30 +163,5 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; /// Strip most part of the key only for secure printing void mask_key_to(char *buffer, size_t len, const char *key); -/// Condition to check if remote peer is online. -template -class WireguardPeerOnlineCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } -}; - -/// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_enabled(); } -}; - -/// Action to enable Wireguard component. -template class WireguardEnableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->enable(); } -}; - -/// Action to disable Wireguard component. -template class WireguardDisableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->disable(); } -}; - } // namespace esphome::wireguard #endif diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 3a24d637609..6deab23828d 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -74,13 +74,13 @@ void XDB401Component::check_measurement_ready_(uint8_t attempt) { // Bit 3 shall be 0 when measurement is ready if ((meas_resp[0] & MASK_MEASURE_READY) == 0) { - ESP_LOGV(TAG, "Meas mode entered after %u ms", attempt * CHECK_DELAY); + ESP_LOGV(TAG, "Meas mode entered after %" PRIu32 " ms", attempt * CHECK_DELAY); this->read_measurement_(); return; } if (attempt >= CHECK_ATTEMPTS) { - ESP_LOGE(TAG, "Device not in measurement mode after timeout of %u ms", CHECK_DELAY * CHECK_ATTEMPTS); + ESP_LOGE(TAG, "Device not in measurement mode after timeout of %" PRIu32 " ms", CHECK_DELAY * CHECK_ATTEMPTS); this->handle_comm_failure_("Measurement timeout"); return; } @@ -129,7 +129,7 @@ i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { // Sign-extend 24-bit big-endian pressure value to int32_t. int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; - ESP_LOGD(TAG, "Pressure data raw %i", raw_pressure); + ESP_LOGD(TAG, "Pressure data raw %" PRId32, raw_pressure); pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * XDB401Component::full_scale_pressure_pa(this->pressure_range_bar_); diff --git a/esphome/components/xiaomi_body_scale/__init__.py b/esphome/components/xiaomi_body_scale/__init__.py new file mode 100644 index 00000000000..3d75ef24ecb --- /dev/null +++ b/esphome/components/xiaomi_body_scale/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@dckiller51"] diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py new file mode 100644 index 00000000000..cb0802d270a --- /dev/null +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -0,0 +1,88 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BINDKEY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_WEIGHT, + DEVICE_CLASS_WEIGHT, + ICON_HEART_PULSE, + ICON_OMEGA, + ICON_SCALE_BATHROOM, + STATE_CLASS_MEASUREMENT, + UNIT_BEATS_PER_MINUTE, + UNIT_KILOGRAM, + UNIT_OHM, +) +from esphome.types import ConfigType + +CONF_IMPEDANCE_LOW = "impedance_low" +CONF_IMPEDANCE_HIGH = "impedance_high" +CONF_HEART_RATE = "heart_rate" +CONF_PROFILE_ID = "profile_id" +CONF_STABILIZED = "stabilized" + +AUTO_LOAD = ["ble_device_base", "binary_sensor"] + +xiaomi_body_scale_ns = cg.esphome_ns.namespace("xiaomi_body_scale") +XiaomiBodyScale = xiaomi_body_scale_ns.class_( + "XiaomiBodyScale", ble_device_base.ESPBTDeviceListener, cg.Component +) + +IMPEDANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiBodyScale), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_WEIGHT): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOGRAM, + accuracy_decimals=2, + device_class=DEVICE_CLASS_WEIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IMPEDANCE_LOW): IMPEDANCE_SCHEMA, + cv.Optional(CONF_IMPEDANCE_HIGH): IMPEDANCE_SCHEMA, + cv.Optional(CONF_HEART_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_BEATS_PER_MINUTE, + icon=ICON_HEART_PULSE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROFILE_ID): sensor.sensor_schema( + icon="mdi:identifier", + accuracy_decimals=0, + ), + cv.Optional(CONF_STABILIZED): binary_sensor.binary_sensor_schema( + icon=ICON_SCALE_BATHROOM, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] + ) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_WEIGHT, var.set_weight) + await sensors(CONF_IMPEDANCE_LOW, var.set_impedance_low) + await sensors(CONF_IMPEDANCE_HIGH, var.set_impedance_high) + await sensors(CONF_HEART_RATE, var.set_heart_rate) + await sensors(CONF_PROFILE_ID, var.set_profile_id) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_STABILIZED, var.set_stabilized) diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp new file mode 100644 index 00000000000..7f9cd17d1f1 --- /dev/null +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp @@ -0,0 +1,141 @@ +#include "xiaomi_body_scale.h" +#include "esphome/components/ble_device_base/ble_aes_ccm.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::xiaomi_body_scale { + +static const char *const TAG = "xiaomi_body_scale"; + +// Encrypted MiBeacon frame without a MAC: frame control, device id, frame count, cipher, counter, tag +static constexpr size_t DEVICE_ID_POS = 2; +static constexpr size_t FRAME_COUNT_POS = 4; +static constexpr size_t CIPHER_POS = 5; +static constexpr size_t CIPHER_SIZE = 12; +static constexpr size_t COUNTER_POS = CIPHER_POS + CIPHER_SIZE; +static constexpr size_t TAG_POS = COUNTER_POS + 3; +static constexpr size_t TAG_SIZE = 4; +static constexpr size_t FRAME_SIZE = TAG_POS + TAG_SIZE; +// Frame control: has data (0x40) and encrypted (0x08) +static constexpr uint8_t FRAME_ENCRYPTED_DATA = 0x48; +static constexpr uint8_t OBJECT_SIZE = 9; +static constexpr uint16_t OBJECT_S200_MEASUREMENT = 0x4E16; +static constexpr uint16_t OBJECT_S400_MEASUREMENT = 0x6E16; +static constexpr uint32_t STABILIZED_RESET_ID = 0; +static constexpr uint32_t STABILIZED_RESET_MS = 1000; + +XiaomiBodyScale::XiaomiBodyScale(uint64_t address, const char *bindkey) : address_(address) { + parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); +} + +void XiaomiBodyScale::dump_config() { + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(this->address_, mac); + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + "Xiaomi Body Scale\n" + " MAC Address: %s", + format_mac_addr_upper(mac, mac_buf)); + LOG_SENSOR(" ", "Weight", this->weight_); + LOG_SENSOR(" ", "Impedance Low (50 kHz)", this->impedance_low_); + LOG_SENSOR(" ", "Impedance High (250 kHz)", this->impedance_high_); + LOG_SENSOR(" ", "Heart Rate", this->heart_rate_); + LOG_SENSOR(" ", "Profile ID", this->profile_id_); + LOG_BINARY_SENSOR(" ", "Stabilized", this->stabilized_); +} + +bool XiaomiBodyScale::decrypt_(const uint8_t *frame, uint8_t *plaintext) const { + uint8_t nonce[MAC_ADDRESS_SIZE + 6]; + for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) + nonce[i] = static_cast(this->address_ >> (i * 8)); // MAC, reversed + memcpy(nonce + MAC_ADDRESS_SIZE, frame + DEVICE_ID_POS, 3); // device id + frame count + memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + COUNTER_POS, 3); + static constexpr uint8_t AUTH_DATA[1] = {0x11}; + return ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce, sizeof(nonce), AUTH_DATA, sizeof(AUTH_DATA), + frame + CIPHER_POS, CIPHER_SIZE, plaintext, frame + TAG_POS, TAG_SIZE); +} + +void XiaomiBodyScale::publish_stabilized_(bool stabilized) { + if (this->stabilized_ == nullptr) + return; + this->stabilized_->publish_state(stabilized); + // Clear it again so the next measurement is seen as a new one + if (stabilized) + this->set_timeout(STABILIZED_RESET_ID, STABILIZED_RESET_MS, [this]() { this->stabilized_->publish_state(false); }); +} + +void XiaomiBodyScale::publish_s400_(uint32_t packed) { + // Weight x10 (11 bits), heart rate - 50 (7 bits), impedance x10 (14 bits). A measurement sends weight, heart + // rate and the 50 kHz impedance (the larger value), then a packet with only the 250 kHz impedance. + const uint16_t weight = packed & 0x7FF; + const uint8_t heart_rate = (packed >> 11) & 0x7F; + const uint16_t impedance = packed >> 18; + ESP_LOGD(TAG, "weight=%u heart_rate=%u impedance=%u", weight, heart_rate, impedance); + + if (weight != 0 && this->weight_ != nullptr) + this->weight_->publish_state(weight / 10.0f); + if (heart_rate > 0 && heart_rate < 127 && this->heart_rate_ != nullptr) + this->heart_rate_->publish_state(heart_rate + 50.0f); + + if (weight == 0 && heart_rate == 0) { + // Impedance only: the final 250 kHz packet (bare feet); all zero: stepped off + if (impedance != 0 && this->impedance_high_ != nullptr) + this->impedance_high_->publish_state(impedance / 10.0f); + this->publish_stabilized_(impedance != 0); + } else if (impedance != 0) { + if (this->impedance_low_ != nullptr) + this->impedance_low_->publish_state(impedance / 10.0f); + this->publish_stabilized_(false); + } else { + // Weight without impedance: measurement complete (with socks) + this->publish_stabilized_(true); + } +} + +bool XiaomiBodyScale::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + for (const auto &service_data : device.get_service_datas()) { + if (service_data.data.size() != FRAME_SIZE || !service_data.uuid.contains(0x95, 0xFE)) + continue; + const uint8_t *frame = service_data.data.data(); + // The bindkey is required, so plaintext frames are never trusted + if ((frame[0] & FRAME_ENCRYPTED_DATA) != FRAME_ENCRYPTED_DATA) + continue; + + if (frame[FRAME_COUNT_POS] == this->last_frame_count_) + continue; + uint8_t object[CIPHER_SIZE]; + if (!this->decrypt_(frame, object)) { + ESP_LOGW(TAG, "Decryption failed, wrong bindkey?"); + continue; + } + // Only an authenticated frame may advance the duplicate filter + this->last_frame_count_ = frame[FRAME_COUNT_POS]; + + // Both objects hold the profile ID, a packed uint32 (LE) and a timestamp (not published) + const uint16_t value_type = encode_uint16(object[1], object[0]); + const uint8_t *data = object + 3; + const uint32_t packed = encode_uint32(data[4], data[3], data[2], data[1]); + const bool sized = object[2] == OBJECT_SIZE; + if (sized && value_type == OBJECT_S400_MEASUREMENT) { + this->publish_s400_(packed); + } else if (sized && value_type == OBJECT_S200_MEASUREMENT) { + // Weight x100 only + if (packed != 0 && this->weight_ != nullptr) + this->weight_->publish_state(packed / 100.0f); + } else { + ESP_LOGVV(TAG, "Unknown object 0x%04X, length %u", value_type, object[2]); + continue; + } + if (this->profile_id_ != nullptr) + this->profile_id_->publish_state(data[0]); + return true; + } + return false; +} + +} // namespace esphome::xiaomi_body_scale diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h new file mode 100644 index 00000000000..4113bd6d267 --- /dev/null +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h @@ -0,0 +1,40 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::xiaomi_body_scale { + +class XiaomiBodyScale final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + XiaomiBodyScale(uint64_t address, const char *bindkey); + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + void set_weight(sensor::Sensor *weight) { this->weight_ = weight; } + void set_impedance_low(sensor::Sensor *impedance_low) { this->impedance_low_ = impedance_low; } + void set_impedance_high(sensor::Sensor *impedance_high) { this->impedance_high_ = impedance_high; } + void set_heart_rate(sensor::Sensor *heart_rate) { this->heart_rate_ = heart_rate; } + void set_profile_id(sensor::Sensor *profile_id) { this->profile_id_ = profile_id; } + void set_stabilized(binary_sensor::BinarySensor *stabilized) { this->stabilized_ = stabilized; } + + protected: + bool decrypt_(const uint8_t *frame, uint8_t *plaintext) const; + void publish_s400_(uint32_t packed); + void publish_stabilized_(bool stabilized); + + uint64_t address_; + uint8_t bindkey_[16]; + sensor::Sensor *weight_{nullptr}; + sensor::Sensor *impedance_low_{nullptr}; + sensor::Sensor *impedance_high_{nullptr}; + sensor::Sensor *heart_rate_{nullptr}; + sensor::Sensor *profile_id_{nullptr}; + binary_sensor::BinarySensor *stabilized_{nullptr}; + uint16_t last_frame_count_{0x100}; // outside the 8-bit range, so the first frame always passes +}; + +} // namespace esphome::xiaomi_body_scale diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 13e8e509c33..11bbceef5e3 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -81,18 +81,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance(sens)) - if CONF_CONDUCTIVITY in config: - sens = await sensor.new_sensor(config[CONF_CONDUCTIVITY]) - cg.add(var.set_conductivity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index d1ce8dfcf77..c5d256db1d3 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -81,18 +81,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if moisture_config := config.get(CONF_MOISTURE): - sens = await sensor.new_sensor(moisture_config) - cg.add(var.set_moisture(sens)) - if illuminance_config := config.get(CONF_ILLUMINANCE): - sens = await sensor.new_sensor(illuminance_config) - cg.add(var.set_illuminance(sens)) - if conductivity_config := config.get(CONF_CONDUCTIVITY): - sens = await sensor.new_sensor(conductivity_config) - cg.add(var.set_conductivity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index f4e24a97d0b..32977d0ed6f 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -68,12 +68,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 57420125cb6..c122b1ce983 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -47,18 +47,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_MOTION in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_MOTION]) - cg.add(parent.set_motion(sens)) - cg.add(parent.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) - - if CONF_LIGHT in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_LIGHT]) - cg.add(parent.set_light(sens)) - - if CONF_BUTTON in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_BUTTON]) - cg.add(parent.set_button(sens)) - cg.add(parent.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) + binary_sensors = binary_sensor.sub_binary_sensors(config) + if await binary_sensors(CONF_MOTION, hub.set_motion): + cg.add(hub.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) + await binary_sensors(CONF_LIGHT, hub.set_light) + if await binary_sensors(CONF_BUTTON, hub.set_button): + cg.add(hub.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e0e4b4640bd..5e849b26bc5 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -31,8 +31,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(parent.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, hub.set_battery_level) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 29ba0b9ed21..49654736cc2 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -176,7 +176,10 @@ def zephyr_to_code(config: ConfigType) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: - if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF][""]: + need_cdc_cb = zephyr_data()[KEY_PRJ_CONF][""].get( + "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", (False,) + )[0] + if need_cdc_cb: var = cg.new_Pvariable(config[CONF_CDC_ACM]) await cg.register_component(var, {}) @@ -207,6 +210,7 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: zephyr_add_prj_conf("CONFIG_USB_DEVICE_STACK_NEXT", False) zephyr_add_prj_conf("USB_DEVICE_STACK", True) zephyr_add_prj_conf("USB_CDC_ACM", True) + zephyr_add_prj_conf("SERIAL", True) # prevent device to go to susspend, without this communication stop working in python # there should be a way to solve it zephyr_add_prj_conf("USB_DEVICE_REMOTE_WAKEUP", False) diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index ad8ed5c95c3..10e8340a409 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -27,7 +27,11 @@ void arch_init() { if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE +#ifndef USE_BOOTLOADER_MCUBOOT + // Adafruit bootloader doesn't feed the WDT while + // erasing flash during a firmware update, so a shorter timeout can break the update. + wdt_config.window.max = 30000; +#elif defined(USE_ZIGBEE) // zboss thread uses a lot of CPU cycles during startup wdt_config.window.max = 10000; #else diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 463b9c08870..2493bd9f959 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,12 +1,56 @@ +from dataclasses import dataclass + from esphome import automation import esphome.codegen as cg +from esphome.components.nrf52.framework import ( + bluetooth_west_projects, + include_west_project, +) from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +# BLE LE Data Length Extension maximum LL PDU payload (BLE spec) +_DLE_MAX_PDU = 251 + +DOMAIN = "zephyr_ble_server" + + +@dataclass +class _BLEServerData: + requested_l2cap_mtu: int = 0 + mtu_emitted: bool = False + + +def _get_data() -> _BLEServerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _BLEServerData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_ble_mtu() -> None: + data = _get_data() + data.mtu_emitted = True + mtu = data.requested_l2cap_mtu + if mtu > 0: + zephyr_add_prj_conf("BT_L2CAP_TX_MTU", mtu) + zephyr_add_prj_conf("BT_BUF_ACL_TX_SIZE", min(mtu + 4, _DLE_MAX_PDU)) + zephyr_add_prj_conf("BT_BUF_ACL_RX_SIZE", mtu + 4) + + +def request_ble_l2cap_mtu(l2cap_mtu: int) -> None: + """Request a minimum BLE L2CAP MTU. The maximum of all callers wins.""" + data = _get_data() + if data.mtu_emitted: + raise RuntimeError("BLE L2CAP MTU requested after it was written to prj.conf") + if data.requested_l2cap_mtu == 0: + CORE.add_job(_emit_ble_mtu) + data.requested_l2cap_mtu = max(data.requested_l2cap_mtu, l2cap_mtu) + + zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -37,6 +81,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT", True) + for project in bluetooth_west_projects(): + include_west_project(project) zephyr_add_prj_conf("BT_PERIPHERAL", True) zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536) zephyr_add_prj_conf("BT_DEVICE_NAME", CORE.name) @@ -46,13 +92,10 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("BT_SETTINGS", True) zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -BLENumericComparisonReplyAction = zephyr_ble_server_ns.class_( - "BLENumericComparisonReplyAction", automation.Action -) - BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEServer), @@ -61,22 +104,8 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ble_server.numeric_comparison_reply", - BLENumericComparisonReplyAction, BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ACCEPT, "numeric_comparison_reply", cg.bool_), ) -async def numeric_comparison_reply_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - templ = await cg.templatable(config[CONF_ACCEPT], args, cg.bool_) - cg.add(var.set_accept(templ)) - - return var diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index 223dbf7ac98..ef3daff9164 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -21,17 +21,5 @@ class BLEServer final : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction final : public Action { - public: - explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(bool, accept) - - void play(const Ts &...x) override { this->parent_->numeric_comparison_reply(this->accept_.value(x...)); } - - protected: - BLEServer *parent_; -}; - } // namespace esphome::zephyr_ble_server #endif diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ad89c1ac794..f5ab04809e4 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,5 +1,7 @@ +from esphome import pins import esphome.codegen as cg from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( HexValue, @@ -13,10 +15,15 @@ from esphome.components.zephyr.const import ( KEY_BOOTLOADER, KEY_SYSBUILD, ) +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_HARDWARE_UART, CONF_ID, + CONF_INVERTED, + CONF_NUMBER, + CONF_PIN, + CONF_STATUS, KEY_CORE, KEY_FRAMEWORK_VERSION, Framework, @@ -65,6 +72,11 @@ CONFIG_SCHEMA = cv.All( ): cv.one_of(*UARTS, upper=True), } ), + cv.Optional(CONF_STATUS): cv.Schema( + { + cv.Required(CONF_PIN): pins.gpio_output_pin_schema, + } + ), } ) .extend(BASE_OTA_SCHEMA) @@ -115,6 +127,9 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_BUF", True) zephyr_add_prj_conf("ZCBOR", True) + include_west_project("zcbor") + # The image manager includes MCUboot headers with any bootloader + include_west_project("mcuboot") zephyr_add_prj_conf("MCUMGR", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG", True) @@ -140,7 +155,9 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) + request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: + zephyr_add_prj_conf("SERIAL", True) uart = UARTS[transport[CONF_HARDWARE_UART]] uart_name = uart[0] cdc_id = uart[1] @@ -158,6 +175,32 @@ async def to_code(config: ConfigType) -> None: }}; """ ) + if CONF_STATUS in config: + pin_conf = config[CONF_STATUS][CONF_PIN] + pin_num = pin_conf[CONF_NUMBER] + port = pin_num // 32 + pin_in_port = pin_num % 32 + active_flag = ( + "GPIO_ACTIVE_LOW" if pin_conf[CONF_INVERTED] else "GPIO_ACTIVE_HIGH" + ) + zephyr_add_prj_conf("MCUBOOT_INDICATION_LED", True, image="mcuboot") + zephyr_add_overlay( + f""" + #include + / {{ + mcuboot_leds {{ + compatible = "gpio-leds"; + mcuboot_led0: mcuboot_led0 {{ + gpios = <&gpio{port} {pin_in_port} {active_flag}>; + }}; + }}; + aliases {{ + mcuboot-led0 = &mcuboot_led0; + }}; + }}; + """, + image="mcuboot", + ) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver >= cv.Version(2, 9, 2): zephyr_data()[KEY_SYSBUILD] = True diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 47913b34d70..e9fef59f353 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any -from esphome import automation, core +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import only_on_variant from esphome.components.esp32.const import ( @@ -18,6 +18,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const import ( + CONF_CLUSTER, CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_ON_JOIN, @@ -30,7 +31,6 @@ from .const import ( POWER_SOURCE, REPORT, ZigbeeComponent, - zigbee_ns, ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, @@ -75,6 +75,11 @@ BASE_SCHEMA = cv.Schema( _check_report_deprecation, cv.enum(REPORT, lower=True), ), + cv.OnlyWith(CONF_CLUSTER, ["esp32", "zigbee"], default="generic"): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.one_of(*["generic", "device_class"], lower=True), + ), cv.Optional(CONF_ENDPOINT): cv.All( cv.requires_component("zigbee"), cv.requires_component("esp32"), @@ -197,21 +202,27 @@ async def to_code(config: ConfigType) -> None: async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: - from .zigbee_zephyr import zephyr_setup_binary_sensor - - await zephyr_setup_binary_sensor(entity, config) + if not config.get(CONF_ZIGBEE_ID): + return + from .zigbee_zephyr import add_binary_sensor + else: + from .zigbee_esp32 import add_component as add_binary_sensor + CORE.add_job(add_binary_sensor, entity, config) async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: - from .zigbee_zephyr import zephyr_setup_sensor - - await zephyr_setup_sensor(entity, config) + if not config.get(CONF_ZIGBEE_ID): + return + from .zigbee_zephyr import add_sensor + else: + from .zigbee_esp32 import add_component as add_sensor + CORE.add_job(add_sensor, entity, config) async def setup_switch(entity: cg.MockObj, config: ConfigType) -> None: @@ -293,23 +304,8 @@ ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id( ) ) -FactoryResetAction = zigbee_ns.class_( - "FactoryResetAction", automation.Action, cg.Parented.template(ZigbeeComponent) -) - - -@automation.register_action( +automation.register_apply_action( "zigbee.factory_reset", - FactoryResetAction, ZIGBEE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("factory_reset()"), ) -async def reset_zigbee_to_code( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> cg.Pvariable: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h deleted file mode 100644 index 1f953100d90..00000000000 --- a/esphome/components/zigbee/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_ZIGBEE -#ifdef USE_ESP32 -#include "zigbee_esp32.h" -#endif -#ifdef USE_NRF52 -#include "zigbee_zephyr.h" -#endif -namespace esphome::zigbee { - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::zigbee - -#endif diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index d922ae372f2..d6abd9d6c07 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -64,6 +64,7 @@ REPORT = { "default": report.ZIGBEE_REPORT_DEFAULT, } +CONF_CLUSTER = "cluster" CONF_ENDPOINT = "endpoint" CONF_MAX_EP_NUMBER = 239 CONF_ON_JOIN = "on_join" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 2e1b09fb221..32ae3e37348 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -1,12 +1,14 @@ import esphome.codegen as cg +ALLOWED_UNITS = "allowed_units" DEVICE_TYPE = "device_type" ROLE = "role" CONF_CLUSTERS = "clusters" CONF_ATTRIBUTES = "attributes" -CONF_CLUSTER = "cluster" -SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" +KEY_ZIGBEE_ATTRIBUTES = "zigbee_attributes" +CONNECT = "connect" +SCALE = "scale" KEY_ZIGBEE_EP = "zigbee_ep" KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl" @@ -14,6 +16,11 @@ KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl" DEVICE_ID = { "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), "SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"), + "LIGHT_SENSOR": cg.RawExpression("EZB_ZHA_LIGHT_SENSOR_DEVICE_ID"), + "OCCUPANCY_SENSOR": cg.RawExpression("EZB_ZHA_OCCUPANCY_SENSOR_DEVICE_ID"), + "TEMPERATURE_SENSOR": cg.RawExpression("EZB_ZHA_TEMPERATURE_SENSOR_DEVICE_ID"), + "PRESSURE_SENSOR": cg.RawExpression("EZB_ZHA_PRESSURE_SENSOR_DEVICE_ID"), + "FLOW_SENSOR": cg.RawExpression("EZB_ZHA_FLOW_SENSOR_DEVICE_ID"), "CUSTOM_ATTR": 0xFFF2, } cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") @@ -22,6 +29,14 @@ CLUSTER_ID = { "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, + "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, + "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, + "FLOW_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT, + "REL_HUMIDITY_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT, + "OCCUPANCY_SENSING": cluster_id.EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING, + "CARBON_DIOXIDE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT, + "PM2_5_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT, } CLUSTER_ROLE = { "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), @@ -31,6 +46,12 @@ attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, + "UINT8": attr_type.EZB_ZCL_ATTR_TYPE_UINT8, + "UINT16": attr_type.EZB_ZCL_ATTR_TYPE_UINT16, + "INT8": attr_type.EZB_ZCL_ATTR_TYPE_INT8, + "INT16": attr_type.EZB_ZCL_ATTR_TYPE_INT16, + "ENUM8": attr_type.EZB_ZCL_ATTR_TYPE_ENUM8, + "ENUM16": attr_type.EZB_ZCL_ATTR_TYPE_ENUM16, "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 92d238629a1..3f14d0a62d2 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include "esphome/core/application.h" namespace esphome::zigbee { @@ -47,6 +48,7 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) { this->synchronize_epoch_(epoch); this->has_time_ = true; }); + App.wake_loop_threadsafe(); } void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 1fb8d1abe44..acf0321b12b 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -23,7 +23,8 @@ void ZigbeeAttribute::set_attr_() { } // Check for error if (state != EZB_ZCL_STATUS_SUCCESS) { - ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); + ESP_LOGE(TAG, "Setting attribute 0x%04X on cluster 0x%04X failed, ZCL status: %u", this->attr_id_, + this->cluster_id_, static_cast(state)); } esp_zigbee_lock_release(); } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index fc229b4e950..47f460406c9 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "esphome/core/automation.h" @@ -47,6 +49,7 @@ class ZigbeeAttribute final : public Component { void set_report(ZigbeeReportT report); #ifdef USE_SENSOR template void connect(sensor::Sensor *sensor); + template void connect(sensor::Sensor *sensor, F &&f); #endif #ifdef USE_BINARY_SENSOR template void connect(binary_sensor::BinarySensor *sensor); @@ -68,6 +71,8 @@ class ZigbeeAttribute final : public Component { bool set_attr_requested_{false}; bool report_requested_{false}; bool force_report_{false}; + template T scale_value_(float value); + template T invalid_value_(); }; template void ZigbeeAttribute::add_attr(T value) { @@ -85,9 +90,44 @@ template void ZigbeeAttribute::set_attr(const T &value) { this->enable_loop(); } +template T ZigbeeAttribute::scale_value_(float value) { + static_assert(sizeof(T) <= 2 || std::is_floating_point_v); + if constexpr (std::is_integral::value) { + const float scaled = this->scale_ * value; + if (std::isnan(value) || scaled < static_cast(std::numeric_limits::lowest()) || + scaled > static_cast(std::numeric_limits::max())) { + return this->invalid_value_(); // 0x8000 / 0xFFFF / 0 for bitmaps + } + return static_cast(lroundf(scaled)); + } + return static_cast(this->scale_ * value); +} + +template T ZigbeeAttribute::invalid_value_() { + if constexpr (std::is_integral_v) { + if constexpr (std::is_signed_v) { + // For signed integer types, NaN is represented by the minimum value + return static_cast(std::numeric_limits::min()); + } + + if (this->attr_type_ >= EZB_ZCL_ATTR_TYPE_UINT8 && this->attr_type_ <= EZB_ZCL_ATTR_TYPE_ENUM16) { + // For unsigned integer types and enum, NaN is represented by the maximum value + return static_cast(std::numeric_limits::max()); + } + + // For other integer types, return 0 as a fallback + return static_cast(0); + } + + return std::numeric_limits::quiet_NaN(); // For floating-point types, return NaN +} + #ifdef USE_SENSOR template void ZigbeeAttribute::connect(sensor::Sensor *sensor) { - sensor->add_on_state_callback([this](float value) { this->set_attr((T) (this->scale_ * value)); }); + sensor->add_on_state_callback([this](float value) { this->set_attr(this->scale_value_(value)); }); +} +template void ZigbeeAttribute::connect(sensor::Sensor *sensor, F &&f) { + sensor->add_on_state_callback([f = std::forward(f), this](float value) { this->set_attr((T) f(value)); }); } #endif #ifdef USE_BINARY_SENSOR diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 700267ef50e..c45f5800459 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -1,8 +1,31 @@ from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE -from esphome.core import CORE +from esphome.const import ( + CONF_ID, + CONF_LAMBDA, + CONF_TYPE, + CONF_VALUE, + DEVICE_CLASS_ATMOSPHERIC_PRESSURE, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_HECTOPASCAL, + UNIT_LITRE_PER_HOUR, + UNIT_LUX, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_PARTS_PER_MILLION, + UNIT_PASCAL, + UNIT_PERCENT, +) +from esphome.core import CORE, Lambda from .const import ( CONF_MAX_EP_NUMBER, @@ -12,70 +35,264 @@ from .const import ( REPORT, ) from .const_esp32 import ( + ALLOWED_UNITS, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, + CONNECT, DEVICE_TYPE, KEY_ZIGBEE_EP, KEY_ZIGBEE_EP_NO_NUM, KEY_ZIGBEE_FIRST_EP_CL, ROLE, + SCALE, ) # endpoint configs: -ep_configs: dict[str, dict[str, Any]] = { - "binary_input": { - DEVICE_TYPE: "SIMPLE_SENSOR", +ANALOG_INPUT_EP = { + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_INPUT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "SINGLE", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "MAP8", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "STRING", + }, + ], + }, + ], +} + +BINARY_INPUT_EP = { + DEVICE_TYPE: "SIMPLE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "BINARY_INPUT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "BOOL", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "MAP8", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "STRING", + }, + ], + }, + ], +} + + +def _pressure_ep(device_type: bool = False) -> dict[str, Any]: + ep = { + ALLOWED_UNITS: [UNIT_HECTOPASCAL, UNIT_PASCAL], CONF_CLUSTERS: [ { - CONF_ID: "BINARY_INPUT", + CONF_ID: "PRESSURE_MEASUREMENT", ROLE: "SERVER", CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x55, - CONF_TYPE: "BOOL", + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "INT16", CONF_REPORT: cv.enum(REPORT, lower=True)("default"), - CONF_DEVICE: None, + CONNECT: True, + SCALE: { + UNIT_HECTOPASCAL: 1, + UNIT_PASCAL: 0.01, + }, }, + ], + }, + ], + } + if device_type: + ep[DEVICE_TYPE] = ( + "PRESSURE_SENSOR" # Sensor that measures pressure of liquids like water + ) + return ep + + +SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { + DEVICE_CLASS_TEMPERATURE: { + ALLOWED_UNITS: [UNIT_CELSIUS], + DEVICE_TYPE: "TEMPERATURE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "TEMPERATURE_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x51, - CONF_TYPE: "BOOL", - }, - { - CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "MAP8", - }, - { - CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "STRING", + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "INT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + SCALE: 100, + CONNECT: True, }, ], }, ], }, - "analog_input": { + DEVICE_CLASS_HUMIDITY: { + ALLOWED_UNITS: [UNIT_PERCENT], CONF_CLUSTERS: [ { - CONF_ID: "ANALOG_INPUT", + CONF_ID: "REL_HUMIDITY_MEASUREMENT", ROLE: "SERVER", CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x55, + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "UINT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + SCALE: 100, + CONNECT: True, + }, + ], + }, + ], + }, + DEVICE_CLASS_ATMOSPHERIC_PRESSURE: _pressure_ep(), + DEVICE_CLASS_PRESSURE: _pressure_ep(device_type=True), + DEVICE_CLASS_VOLUME_FLOW_RATE: { + ALLOWED_UNITS: [UNIT_LITRE_PER_HOUR, UNIT_CUBIC_METER_PER_HOUR], + DEVICE_TYPE: "FLOW_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "FLOW_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "UINT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + SCALE: { + UNIT_LITRE_PER_HOUR: 0.01, + UNIT_CUBIC_METER_PER_HOUR: 10, + }, + }, + ], + }, + ], + }, + DEVICE_CLASS_ILLUMINANCE: { + ALLOWED_UNITS: [UNIT_LUX], + DEVICE_TYPE: "LIGHT_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "ILLUMINANCE_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "UINT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONF_LAMBDA: cv.lambda_( + Lambda( + "if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN + " if (x < 1.0f) return 0;" # too small to measure + " const float v = log10(x)*10000 + 1;" + " return v > 65534.0f ? 0xFFFE : (uint16_t) lroundf(v);" # clamp to 0xFFFE if too large + ) + ), + CONNECT: True, + }, + ], + }, + ], + }, + DEVICE_CLASS_PM25: { + ALLOWED_UNITS: [UNIT_MICROGRAMS_PER_CUBIC_METER], + CONF_CLUSTERS: [ + { + CONF_ID: "PM2_5_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "SINGLE", CONF_REPORT: cv.enum(REPORT, lower=True)("default"), - CONF_DEVICE: None, + CONNECT: True, }, { - CONF_ATTRIBUTE_ID: 0x51, - CONF_TYPE: "BOOL", + CONF_ATTRIBUTE_ID: 0x2, + CONF_TYPE: "SINGLE", + CONF_VALUE: 9999, # overwrite default 1.0 }, + ], + }, + ], + }, + DEVICE_CLASS_CARBON_DIOXIDE: { + ALLOWED_UNITS: [UNIT_PARTS_PER_MILLION], + CONF_CLUSTERS: [ + { + CONF_ID: "CARBON_DIOXIDE_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x6F, + CONF_ATTRIBUTE_ID: 0x0000, + CONF_TYPE: "SINGLE", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + SCALE: 0.000001, + }, + {CONF_ATTRIBUTE_ID: 0x0001, CONF_TYPE: "SINGLE", CONF_VALUE: 0.0}, + {CONF_ATTRIBUTE_ID: 0x0002, CONF_TYPE: "SINGLE", CONF_VALUE: 0.1}, + ], + }, + ], + }, +} + +BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { + DEVICE_CLASS_OCCUPANCY: { + DEVICE_TYPE: "OCCUPANCY_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "OCCUPANCY_SENSING", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "MAP8", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, }, { - CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "STRING", + CONF_ATTRIBUTE_ID: 0x1, + CONF_TYPE: "ENUM8", + CONF_VALUE: 0, # hardcode PIR for now as ultrasonic or physical contact is unlikely + }, + { + CONF_ATTRIBUTE_ID: 0x2, + CONF_TYPE: "MAP8", + CONF_VALUE: 0b00000001, # hardcode PIR for now as ultrasonic or physical contact is unlikely }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index cd094306f43..c00b13cffd8 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,7 +49,8 @@ void ZigbeeComponent::factory_reset() { void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + App.wake_loop_threadsafe(); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -88,6 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { global_zigbee->set_timeout("zb_init", 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); + App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -113,6 +115,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } + App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { @@ -178,13 +181,8 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi } void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) { - ezb_af_ep_config_t config = { - .ep_id = endpoint_id, - .app_profile_id = EZB_AF_HA_PROFILE_ID, - .app_device_id = device_id, - .app_device_version = 0, - }; - ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config); + ezb_af_ep_desc_t ep_desc = + esphome_zb_zha_default_ep_desc_create(endpoint_id, device_id, this->basic_cluster_data_.power_source); if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) { ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id); } @@ -229,13 +227,13 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { .power_source = this->basic_cluster_data_.power_source, }; cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER); + ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, this->basic_cluster_data_.manufacturer); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); - ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } bool ZigbeeComponent::register_device() { @@ -269,7 +267,9 @@ static void ezb_task(void *pv_parameters) { vTaskDelete(NULL); } -ZigbeeComponent::ZigbeeComponent() { +void ZigbeeComponent::setup() { + global_zigbee = this; + esp_zigbee_platform_config_t platform_config = { .storage_partition_name = "nvs", .radio_config = EZB_DEFAULT_RADIO_CONFIG(), @@ -296,11 +296,7 @@ ZigbeeComponent::ZigbeeComponent() { this->mark_failed(); return; } - this->dev_desc_ = ezb_af_create_device_desc(); -} -void ZigbeeComponent::setup() { - global_zigbee = this; #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); @@ -338,6 +334,15 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); + // Finish zigbee data model + for (auto &attr_value : this->attr_values_) { + ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; + void *value_p = &attr_value.value; + ezb_zcl_attr_desc_set_value(attr_desc, value_p); + } + // free memory + std::vector().swap(this->attr_values_); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -380,6 +385,23 @@ void ZigbeeComponent::dump_config() { YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } + +bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return false; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return false; + } + if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { + return false; + } + ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, + endpoint_id, cluster_id); + return true; +} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index c19fc3ad63e..e84ed9817cd 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -30,11 +30,15 @@ static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); +struct AttrValue { + ezb_zcl_attr_desc_t attr_desc; + uint8_t value[4]; +}; + class ZigbeeAttribute; class ZigbeeComponent final : public Component { public: - ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; @@ -88,7 +92,8 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - ezb_af_device_desc_t dev_desc_; + std::vector attr_values_; + ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; LazyCallbackManager start_cb_{}; bool start_reported_{false}; @@ -96,6 +101,8 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; + // TODO: remove when esp-zigbee-lib fixes set_value before init + bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -110,10 +117,22 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { + if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } + return; + } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { + if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } + return; + } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -133,7 +152,18 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); + // TODO: revert when esp-zigbee-lib fixes set_value before init + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + + if (attr_desc != NULL) { + static_assert(sizeof(*value_p) <= 4); + AttrValue attr_value; + attr_value.attr_desc = attr_desc; + memcpy(&attr_value.value, value_p, sizeof(*value_p)); + attr_values_.push_back(attr_value); + } else { + esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); + } if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 57fa3b2a007..d5e8ebbf5ca 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -10,15 +10,16 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_tls_extras, require_vfs_select, ) import esphome.config_validation as cv from esphome.const import ( CONF_ACCURACY_DECIMALS, CONF_AP, - CONF_DEVICE, CONF_DEVICE_CLASS, CONF_ID, + CONF_LAMBDA, CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, @@ -37,6 +38,7 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_CLUSTER, CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, @@ -47,19 +49,29 @@ from .const import ( ZigbeeAttribute, ) from .const_esp32 import ( + ALLOWED_UNITS, ATTR_TYPE, CLUSTER_ID, CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, + CONNECT, DEVICE_ID, DEVICE_TYPE, + KEY_ZIGBEE_ATTRIBUTES, KEY_ZIGBEE_EP, ROLE, SCALE, ) -from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs +from .zigbee_ep_esp32 import ( + ANALOG_INPUT_EP, + BINARY_INPUT_EP, + BINARY_SENSOR_EP_CONFIGS, + SENSOR_EP_CONFIGS, + add_ep, + create_ep, +) _LOGGER = logging.getLogger(__name__) @@ -78,8 +90,11 @@ def get_c_type(attr_type: str) -> Any | None: if "STRING" in attr_type: return cg.std_string test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) - if test and test.group(2): + if test: return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) + test = re.match(r"^INT(\d{1,2})$", attr_type) + if test: + return getattr(cg, "int" + get_c_size(test.group(1), [8, 16, 32, 64])) return None @@ -91,8 +106,11 @@ def get_cv_by_type(attr_type: str) -> Any | None: if "STRING" in attr_type: return cv.string test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) - if test and test.group(2): + if test: return cv.positive_int + test = re.match(r"^INT(\d{1,2})$", attr_type) + if test: + return cv.int_ raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") @@ -103,6 +121,14 @@ def get_default_by_type(attr_type: str) -> str | bool | int | float: return False if attr_type in ["SINGLE", "DOUBLE"]: return float("nan") + test = re.match(r"^(UINT|ENUM)(\d{1,2})$", attr_type) + if test: + # ZCL "invalid value" sentinel for unsigned ints is the maximum (0xFFFF for UINT16) + return 2 ** (int(test.group(2))) - 1 + test = re.match(r"^INT(\d{1,2})$", attr_type) + if test: + # ZCL "invalid value" sentinel for signed ints is the minimum (0x8000 for INT16) + return -(1 << (int(test.group(1)) - 1)) return 0 @@ -166,59 +192,84 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None ) # or use unidecode attr[CONF_VALUE] = str(name) attr[CONF_MAX_LENGTH] = len(str(name)) - if CONF_DEVICE in attr: # connect device - attr[CONF_DEVICE] = config[CONF_ID] + if attr.get(CONNECT): # connect device if CONF_REPORT in config: attr[CONF_REPORT] = config[CONF_REPORT] attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) - if "zb_attr_ids" not in config: - config["zb_attr_ids"] = [] - config["zb_attr_ids"].append(attr[CONF_ID]) + attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, []) + attr_list.append(attr) else: attr[CONF_ID] = None validate_attributes(attr) def validate_sensor_esp32(config: ConfigType) -> ConfigType: - ep = copy.deepcopy(ep_configs["analog_input"]) # get application type from device class and meas unit # if none get BACNET unit from meas unit dev_class = config.get(CONF_DEVICE_CLASS) unit = config.get(CONF_UNIT_OF_MEASUREMENT) - apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) - bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) - accuracy = config.get(CONF_ACCURACY_DECIMALS) - if apptype is not None: + if config[CONF_CLUSTER] == "device_class": + if dev_class not in SENSOR_EP_CONFIGS: + raise cv.Invalid( + "'cluster: device_class' requires a supported 'device_class'. " + f"Supported: {', '.join(SENSOR_EP_CONFIGS)}. Use 'cluster: generic' otherwise." + ) + ep = copy.deepcopy(SENSOR_EP_CONFIGS[dev_class]) + if unit not in ep[ALLOWED_UNITS]: + raise cv.Invalid( + f"Device class '{dev_class}' requires one of units {', '.join(ep[ALLOWED_UNITS])}." + ) + for attr in ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES]: + if isinstance(attr.get(SCALE), dict): + attr[SCALE] = attr[SCALE][unit] + if isinstance(attr.get(CONF_LAMBDA), dict): + attr[CONF_LAMBDA] = attr[CONF_LAMBDA][unit] + else: + ep = copy.deepcopy(ANALOG_INPUT_EP) + apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) + bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + accuracy = config.get(CONF_ACCURACY_DECIMALS) + if apptype is not None: + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x100, + CONF_VALUE: (apptype << 16) | 0xFFFF, + CONF_TYPE: "UINT32", + }, + ) ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { - CONF_ATTRIBUTE_ID: 0x100, - CONF_VALUE: (apptype << 16) | 0xFFFF, - CONF_TYPE: "UINT32", - }, - ) - ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( - { - CONF_ATTRIBUTE_ID: 0x75, - CONF_VALUE: bacunit, - CONF_TYPE: "ENUM16", - }, - ) - if accuracy is not None: - # Analog Input Resolution (0x006A): smallest reportable change - ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( - { - CONF_ATTRIBUTE_ID: 0x6A, - CONF_VALUE: 10**-accuracy, - CONF_TYPE: "SINGLE", + CONF_ATTRIBUTE_ID: 0x75, + CONF_VALUE: bacunit, + CONF_TYPE: "ENUM16", }, ) + if accuracy is not None: + # Analog Input Resolution (0x006A): smallest reportable change + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x6A, + CONF_VALUE: 10**-accuracy, + CONF_TYPE: "SINGLE", + }, + ) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: - ep = copy.deepcopy(ep_configs["binary_input"]) + dev_class = config.get(CONF_DEVICE_CLASS) + if config[CONF_CLUSTER] == "device_class": + if dev_class in BINARY_SENSOR_EP_CONFIGS: + ep = copy.deepcopy(BINARY_SENSOR_EP_CONFIGS[dev_class]) + else: + raise cv.Invalid( + "'cluster: device_class' requires a supported 'device_class'. " + f"Supported: {', '.join(BINARY_SENSOR_EP_CONFIGS)}. Use 'cluster: generic' otherwise." + ) + else: + ep = copy.deepcopy(BINARY_INPUT_EP) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -273,15 +324,11 @@ async def attributes_to_code( ) await cg.register_component(attr_var, attr) - cg.add(attr_var.add_attr(attr[CONF_VALUE])) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + cg.add(attr_var.add_attr(template_arg, attr[CONF_VALUE])) if CONF_REPORT in attr: cg.add(attr_var.set_report(attr[CONF_REPORT])) - if CONF_DEVICE in attr: - device = await cg.get_variable(attr[CONF_DEVICE]) - template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) - cg.add(attr_var.connect(template_arg, device)) - async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( @@ -289,6 +336,12 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": ref="2.0.4", ) + # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Keep the esp32 component from trimming them out of mbedTLS. + require_mbedtls_tls_extras( + ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") + ) + if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present include_builtin_idf_component("esp_coex") @@ -325,3 +378,19 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": ) await attributes_to_code(var, ep_num, cl) return var + + +async def add_component(entity: cg.MockObj, config: ConfigType) -> None: + attrs = config.get(KEY_ZIGBEE_ATTRIBUTES, []) + for attr in attrs: + zb_attr = await cg.get_variable(attr[CONF_ID]) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + if attr.get(CONF_LAMBDA) is not None: + lambda_ = await cg.process_lambda( + attr[CONF_LAMBDA], + [(cg.float_, "x")], + return_type=get_c_type(attr[CONF_TYPE]), + ) + cg.add(zb_attr.connect(template_arg, entity, lambda_)) + else: + cg.add(zb_attr.connect(template_arg, entity)) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 0793669955a..53f2c31e8d4 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -24,6 +24,36 @@ ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } +ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source) { + ezb_af_ep_desc_t ep_desc; + switch (device_id) { + case EZB_ZHA_LIGHT_SENSOR_DEVICE_ID: { + ezb_zha_light_sensor_config_t config = EZB_ZHA_LIGHT_SENSOR_CONFIG(); + config.basic_cfg.power_source = power_source; + ep_desc = ezb_zha_create_light_sensor(ep_id, &config); + break; + } + case EZB_ZHA_TEMPERATURE_SENSOR_DEVICE_ID: { + ezb_zha_temperature_sensor_config_t config = EZB_ZHA_TEMPERATURE_SENSOR_CONFIG(); + config.basic_cfg.power_source = power_source; + ep_desc = ezb_zha_create_temperature_sensor(ep_id, &config); + break; + } + default: { + // For other device IDs no macro exists, so we create an empty endpoint descriptor + ezb_af_ep_config_t config = { + .ep_id = ep_id, + .app_profile_id = EZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0, + }; + ep_desc = ezb_af_create_endpoint_desc(&config); + break; + } + } + return ep_desc; +} + ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) { switch (cluster_id) { case EZB_ZCL_CLUSTER_ID_BASIC: @@ -36,6 +66,22 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT: + return ezb_zcl_illuminance_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT: + return ezb_zcl_temperature_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT: + return ezb_zcl_pressure_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT: + return ezb_zcl_flow_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT: + return ezb_zcl_rel_humidity_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING: + return ezb_zcl_occupancy_sensing_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT: + return ezb_zcl_carbon_dioxide_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT: + return ezb_zcl_pm2_5_measurement_create_cluster_desc(NULL, role_mask); default: { ezb_zcl_custom_cluster_config_t config = {0}; config.cluster_id = cluster_id; @@ -57,6 +103,22 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_ return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT: + return ezb_zcl_illuminance_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT: + return ezb_zcl_temperature_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT: + return ezb_zcl_pressure_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT: + return ezb_zcl_flow_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT: + return ezb_zcl_rel_humidity_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING: + return ezb_zcl_occupancy_sensing_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT: + return ezb_zcl_carbon_dioxide_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT: + return ezb_zcl_pm2_5_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); default: return EZB_ERR_NOT_FOUND; } diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h index 6898068b442..305ab2224c7 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -10,6 +10,7 @@ extern "C" { #include "esp_zigbee.h" +ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source); ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index b8bb0a20369..286c83b8f56 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,10 +1,10 @@ #include "zigbee_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) #include "esphome/core/log.h" +#include "esphome/core/application.h" #include #include #include "esphome/core/hal.h" -#include "esphome/core/wake.h" extern "C" { #include @@ -120,7 +120,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - esphome::wake_loop_threadsafe(); + App.wake_loop_threadsafe(); // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { @@ -138,6 +138,7 @@ void ZigbeeComponent::on_join_(bool factory_new) { ESP_LOGD(TAG, "Joined the network"); this->join_cb_.call(factory_new); }); + App.wake_loop_threadsafe(); } void ZigbeeComponent::on_start_() { @@ -145,6 +146,7 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); + App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f47cf6bd40e..588305da300 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -314,14 +314,6 @@ async def _ctx_to_code(config: ConfigType) -> None: cg.add(cg.RawExpression("ZB_AF_REGISTER_DEVICE_CTX(&zb_device_ctx)")) -async def zephyr_setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: - CORE.add_job(_add_binary_sensor, entity, config) - - -async def zephyr_setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: - CORE.add_job(_add_sensor, entity, config) - - async def zephyr_setup_switch(entity: cg.MockObj, config: ConfigType) -> None: CORE.add_job(_add_switch, entity, config) @@ -404,7 +396,7 @@ async def _add_zigbee_ep( cg.add(var.set_parent(hub)) -async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: +async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: await _add_zigbee_ep( entity, config, @@ -416,7 +408,7 @@ async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: ) -async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None: +async def add_sensor(entity: cg.MockObj, config: ConfigType) -> None: # Get BACnet engineering unit from unit_of_measurement unit = config.get(CONF_UNIT_OF_MEASUREMENT, "") bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 685a9d04b3f..579da9a315e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress +import copy from datetime import datetime from ipaddress import ( AddressValueError, @@ -15,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -419,6 +421,37 @@ class Required(vol.Required): self.visibility: Visibility | None = visibility +def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema: + """Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``. + + Lets a platform override the editor :class:`Visibility` of fields it + inherits from a shared schema builder — without that builder needing a + visibility parameter of its own. The canonical use is a ``template`` + platform promoting the value metadata its user is expected to define + (``device_class``, ``unit_of_measurement``, …) onto the main form: + + CONFIG_SCHEMA = cv.with_visibility( + sensor.sensor_schema(TemplateSensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, + ) + + The original marker's key, default and validator are preserved; only the + visibility changes, and the input ``schema`` is left untouched. Raises if + a requested key is not present so typos fail at schema-build time. + """ + wanted = {str(k) for k in keys} + overrides = {} + for marker, validator in schema.schema.items(): + if str(marker) in wanted: + marker = copy.copy(marker) + marker.visibility = visibility + overrides[marker] = validator + if missing := wanted - {str(m) for m in overrides}: + raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}") + return schema.extend(overrides) + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" @@ -1967,38 +2000,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" @@ -2308,6 +2354,7 @@ def maybe_simple_value(*validators, **kwargs): return validator(value) return validator({key: value}) + validate.inner_schema = validator return validate diff --git a/esphome/const.py b/esphome/const.py index e1d875f94bf..fb02d702810 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -23,12 +23,14 @@ class Toolchain(StrEnum): SDK_NRF = "sdk-nrf" # ESP8266: the Arduino core built directly (no PlatformIO) ARDUINO = "arduino" + # host: the system C/C++ compiler driven by ninja (no PlatformIO) + HOST = "host" # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. -NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST}) class Platform(StrEnum): @@ -352,7 +354,6 @@ CONF_DIRECTION = "direction" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" @@ -1345,6 +1346,7 @@ DEVICE_CLASS_GARAGE = "garage" DEVICE_CLASS_GARAGE_DOOR = "garage_door" DEVICE_CLASS_GAS = "gas" DEVICE_CLASS_GATE = "gate" +DEVICE_CLASS_GLASS_BREAK = "glass_break" DEVICE_CLASS_HEAT = "heat" DEVICE_CLASS_HUMIDITY = "humidity" DEVICE_CLASS_IDENTIFY = "identify" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 6e3f91af22f..fa3d6bb64ed 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -52,8 +52,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing -KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# Key for the controllers (APIServer, WebServer) that receive entity state updates +KEY_CONTROLLER_REGISTRY_CONTROLLERS = "controller_registry_controllers" # CORE.data key for the "is_rp2040 deprecation warning already fired this # run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared @@ -715,6 +715,7 @@ class EsphomeCore: self.defines = set() self.platformio_options = {} self.loaded_integrations = set() + self.loaded_platforms = set() self.component_ids = set() self.platform_counts = defaultdict(int) self.unique_ids = {} @@ -995,6 +996,12 @@ class EsphomeCore: ``using_arduino``, which is the target framework).""" return self.toolchain == Toolchain.ARDUINO + @property + def using_toolchain_host(self): + """The native host build toolchain: the system compiler driven by + ninja (the only toolchain the host platform serves).""" + return self.toolchain == Toolchain.HOST + @property def using_native_toolchain(self): """Whether the selected toolchain builds natively, without reading @@ -1208,10 +1215,9 @@ class EsphomeCore: if not self.platform_counts[platform_name]: self.platform_counts[platform_name] = 1 - def register_controller(self) -> None: - """Track registration of a Controller for ControllerRegistry StaticVector sizing.""" - controller_count = self.data.setdefault(KEY_CONTROLLER_REGISTRY_COUNT, 0) - self.data[KEY_CONTROLLER_REGISTRY_COUNT] = controller_count + 1 + def register_controller(self, controller: "MockObj") -> None: + """Register a controller that receives every entity state update.""" + self.data.setdefault(KEY_CONTROLLER_REGISTRY_CONTROLLERS, []).append(controller) @property def cpp_main_section(self): diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 38d3503c2c3..50d1c619595 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -11,6 +11,19 @@ #include #include #include +#include +#if __has_include() +#include // ESP-IDF 6 +#include +#else +#include +#include +#endif +// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all +#define ESPHOME_FLASH_VENDOR_DRIVERS \ + (CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP) #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -157,8 +170,25 @@ void Application::process_dump_config_() { esp_chip_info(&chip_info); ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, chip_info.revision % 100, chip_info.cores); -#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM)) - static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; + [[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 0 + { + // Only the driver in use earns its IRAM; with several linked at least one is idle + const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 1 + constexpr bool idle_driver = true; +#else + const bool idle_driver = flash_driver == &esp_flash_chip_generic; +#endif + if (idle_driver) { + const char *value = flash_driver->name; +#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP + if (flash_driver == &esp_flash_chip_mxic_opi) + value = "mxic_opi"; +#endif + ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH); + } + } #endif #if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET) { diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac88..8ed4c09096a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -87,7 +87,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -528,7 +528,7 @@ class Application { // 1-byte members (grouped together to minimize padding) uint8_t app_state_{0}; - bool name_add_mac_suffix_; + bool name_add_mac_suffix_{false}; bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ea522a4d2da..61f34d70f45 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -317,13 +317,8 @@ template class TemplatableValue { case STATIC_STRING: return std::string(this->static_str_); #ifdef USE_ESP8266 - case FLASH_STRING: { - // PROGMEM pointer — must use _P functions to access on ESP8266 - size_t len = strlen_P(this->static_str_); - std::string result(len, '\0'); - memcpy_P(result.data(), this->static_str_, len); - return result; - } + case FLASH_STRING: + return progmem_string(reinterpret_cast(this->static_str_)); #endif case NONE: default: @@ -491,7 +486,7 @@ template class Action { this->play(x...); this->play_next_(x...); } - virtual void stop_complex() { + void stop_complex() { if (num_running_) { this->stop(); this->num_running_ = 0; @@ -499,7 +494,7 @@ template class Action { this->stop_next_(); } /// Check if this or any of the following actions are currently running. - virtual bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } + bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } /// The total number of actions that are currently running in this plus any of /// the following actions in the chain. @@ -608,7 +603,9 @@ template class ActionList { template class Automation { public: /// Default constructor for use with TriggerForwarder (no Trigger object needed). - Automation() = default; + // User provided, not "= default": `new(p) Automation()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr Automation() noexcept {} explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 276b8aa9728..8762cbe7110 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include namespace esphome { @@ -180,7 +181,9 @@ class ProjectUpdateTrigger : public Trigger, public Component { template class DelayAction : public Action { public: - explicit DelayAction() = default; + // User provided, not "= default": `new(p) DelayAction()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr explicit DelayAction() noexcept {} TEMPLATABLE_VALUE(uint32_t, delay) @@ -250,6 +253,21 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; +/// Runs one codegen-generated function that has the parent and every field baked in. The +/// function is a template argument, so play() calls it directly and the object is just the +/// Action base. Args pass by const reference so a std::string arg is never copied; +/// StatelessLambdaAction keeps by-value parameters because user `lambda:` code owns them. +template class ApplyAction final : public Action { + public: + void play(const Ts &...x) override { Fn(x...); } +}; + +/// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. +template class ApplyCondition final : public Condition { + public: + bool check(const Ts &...x) override { return Fn(x...); } +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index edbc7714720..ba8a594340f 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,18 +6,13 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); -Color Color::gradient(const Color &to_color, uint8_t amnt) { - uint8_t inv = 255 - amnt; - Color new_color; - new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255; - new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255; - new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255; - new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255; - return new_color; +Color Color::gradient(const Color &to_color, uint8_t amnt) const { + return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt), + blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt)); } -Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } +Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); } -Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } +Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); } } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 442470623df..c7fd522e1a5 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,9 +174,15 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt); - Color fade_to_white(uint8_t amnt); - Color fade_to_black(uint8_t amnt); + /// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a + /// per pixel loop can blend without a call; gradient() itself stays out of + /// line so the light effects and fade_to_*() share one copy. + static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE { + return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255; + } + Color gradient(const Color &to_color, uint8_t amnt) const; + Color fade_to_white(uint8_t amnt) const; + Color fade_to_black(uint8_t amnt) const; Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index fac09e9e145..3c11d4a880f 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/controller.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/helpers.h" #ifdef USE_CAMERA diff --git a/esphome/core/config.py b/esphome/core/config.py index 67a7b5210ee..bb3f03d3663 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -46,7 +46,7 @@ from esphome.const import ( ) from esphome.core import ( CORE, - KEY_CONTROLLER_REGISTRY_COUNT, + KEY_CONTROLLER_REGISTRY_CONTROLLERS, CoroPriority, coroutine_with_priority, ) @@ -555,7 +555,13 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri # that is stored rather than translated away. Consumed by the esp8266 native # backend (later in this chain) for its ignored-option warning; defined here # so it stays adjacent to the routing. -NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"} +# build_src_flags and board_build.flash_mode are 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) @@ -674,12 +680,22 @@ async def _add_platform_defines() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def _add_controller_registry_define() -> None: - # Generate StaticVector size for ControllerRegistry - controller_count = CORE.data.get(KEY_CONTROLLER_REGISTRY_COUNT, 0) - if controller_count > 0: - cg.add_define("USE_CONTROLLER_REGISTRY") - cg.add_define("CONTROLLER_REGISTRY_MAX", controller_count) +async def _add_controller_registry_dispatch() -> None: + # controller_dispatch.h defines ControllerRegistry::notify_*() as direct + # calls on the controllers returned by esphome_controllers(), emitted as + # static auto esphome_controllers() { return std::tuple{a, b}; } + controllers = CORE.data.get(KEY_CONTROLLER_REGISTRY_CONTROLLERS) + if not controllers: + return + cg.add_define("USE_CONTROLLER_REGISTRY") + controllers = cg.ArrayInitializer(*controllers) + cg.add_global(cg.RawStatement("#include ")) + cg.add_global( + cg.RawStatement( + f"static auto esphome_controllers() {{ return std::tuple{controllers}; }}" + ) + ) + cg.add_global(cg.RawStatement('#include "esphome/core/controller_dispatch.h"')) @coroutine_with_priority(CoroPriority.FINAL) @@ -717,9 +733,10 @@ async def to_code(config: ConfigType) -> None: cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) - # Construct App via placement new — see application.cpp for storage details + # Construct App via placement new — see application.cpp for storage details. + # No parens: `Application()` would zero-fill storage that is already zero. cg.add_global(cg.RawStatement("#include ")) - cg.add(cg.RawExpression("new (&App) Application()")) + cg.add(cg.RawExpression("new (&App) Application")) name = config[CONF_NAME] friendly_name = config[CONF_FRIENDLY_NAME] name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX] @@ -754,7 +771,7 @@ async def to_code(config: ConfigType) -> None: ) CORE.add_job(_add_platform_defines) - CORE.add_job(_add_controller_registry_define) + CORE.add_job(_add_controller_registry_dispatch) CORE.add_job(_add_looping_components) CORE.add_job(_add_automations, config) @@ -768,6 +785,7 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + cg.add_build_flag("-Wno-unused-function") # C++20 deprecated ++/--, compound assignment, and chained assignment on # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. # C++23 (P2327R1) removed the deprecation for compound assignment, so the diff --git a/esphome/core/controller.h b/esphome/core/controller.h deleted file mode 100644 index 09975b465f8..00000000000 --- a/esphome/core/controller.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/entity_includes.h" - -namespace esphome { - -class Controller { - public: -// Controller virtual methods (generated from entity_types.h) -// NOLINTBEGIN(bugprone-macro-parentheses) -#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback -#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; -#include "esphome/core/entity_types.h" -#undef ENTITY_TYPE_ -#undef ENTITY_CONTROLLER_TYPE_ - // NOLINTEND(bugprone-macro-parentheses) -}; - -} // namespace esphome diff --git a/esphome/core/controller_dispatch.h b/esphome/core/controller_dispatch.h new file mode 100644 index 00000000000..fc6a03556df --- /dev/null +++ b/esphome/core/controller_dispatch.h @@ -0,0 +1,50 @@ +#pragma once + +// Included once by the generated main.cpp, after it returns the registered controllers as a tuple: +// +// static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; } +// #include "esphome/core/controller_dispatch.h" +// +// Defines ControllerRegistry::notify_*() as direct calls on those controllers. Excluded from +// esphome.h and the clang-tidy all-headers file, so nothing else includes it. + +#include +#include + +#include "esphome/core/controller_registry.h" + +namespace esphome { + +// NOLINTBEGIN(bugprone-macro-parentheses) + +/// A controller provides a plain on_*_update() member for every entity type in the build. +template +concept ControllerContract = requires(T &controller) { + controller; // keeps the requirement list non-empty when no entity type has a callback +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + controller.on_##callback(static_cast(nullptr)); +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +}; + +template constexpr bool controllers_satisfy_contract(std::tuple *) { + return (ControllerContract> && ...); +} +static_assert(controllers_satisfy_contract(static_cast(nullptr)), + "A registered controller is missing an on_*_update() callback for an entity type in this build " + "(ControllerContract in esphome/core/controller_dispatch.h)"); + +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + void ControllerRegistry::notify_##callback(type *obj) { \ + std::apply([obj](auto *...controller) { (controller->on_##callback(obj), ...); }, esphome_controllers()); \ + } +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + +// NOLINTEND(bugprone-macro-parentheses) + +} // namespace esphome diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp deleted file mode 100644 index 907e0f923d6..00000000000 --- a/esphome/core/controller_registry.cpp +++ /dev/null @@ -1,11 +0,0 @@ -#include "esphome/core/controller_registry.h" - -#ifdef USE_CONTROLLER_REGISTRY - -namespace esphome { - -StaticVector ControllerRegistry::controllers; - -} // namespace esphome - -#endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index c6113116ffd..8b1b6c7f9d5 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -5,37 +5,18 @@ #ifdef USE_CONTROLLER_REGISTRY #include "esphome/core/entity_includes.h" -#include "esphome/core/helpers.h" namespace esphome { -class Controller; - -/** Global registry for Controllers to receive entity state updates. +/** Fan-out of entity state updates to the controllers (APIServer, WebServer). * - * This singleton registry allows Controllers (APIServer, WebServer) to receive - * entity state change notifications without storing per-entity callbacks. - * - * Instead of each entity maintaining controller callbacks (32 bytes overhead per entity), - * entities call ControllerRegistry::notify_*_update() which iterates the small list - * of registered controllers (typically 2: API and WebServer). - * - * Each notify method directly iterates controllers and calls the virtual method, - * avoiding function pointer indirection for minimal dispatch overhead. - * - * Memory savings: 32 bytes per entity (2 controllers × 16 bytes std::function overhead) - * Typical config (25 entities): ~780 bytes saved - * Large config (80 entities): ~2,540 bytes saved + * Entities call ControllerRegistry::notify_*_update() instead of holding + * per-entity controller callbacks. The functions are only declared here; + * controller_dispatch.h, included by the generated main.cpp, defines them as + * direct calls on the controllers registered through CORE.register_controller(). */ class ControllerRegistry { public: - /** Register a controller to receive entity state updates. - * - * Controllers should call this in their setup() method. - * Typically only APIServer and WebServer register. - */ - static void register_controller(Controller *controller) { controllers.push_back(controller); } - // Notify method declarations (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback @@ -45,38 +26,8 @@ class ControllerRegistry { #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) - - protected: - static StaticVector controllers; }; } // namespace esphome -// Include controller.h AFTER the class definition so notify methods can be -// defined inline. This is safe because controller_registry.h is only ever -// included from .cpp files, never from other headers. -#include "esphome/core/controller.h" - -namespace esphome { - -// Inline notify methods — each is a tiny loop over 1-2 controllers. -// Defining them here (rather than in controller_registry.cpp) allows the -// compiler to inline them into the single call site in each entity's -// notify_frontend_(), eliminating an unnecessary function-call frame. - -// NOLINTBEGIN(bugprone-macro-parentheses) -#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback -#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ - inline void ControllerRegistry::notify_##callback(type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##callback(obj); \ - } \ - } -#include "esphome/core/entity_types.h" -#undef ENTITY_TYPE_ -#undef ENTITY_CONTROLLER_TYPE_ -// NOLINTEND(bugprone-macro-parentheses) - -} // namespace esphome - #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9dd1e0ced64..f845154389b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,24 +70,30 @@ #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE -#define USE_ESP32_IMPROV_STATE_CALLBACK +#define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN #define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU +#define USE_HOERMANN_HCP_IDENTITY #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE +#define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF #define USE_JSON +#define USE_JSON_ARENA #define USE_RADIO_FREQUENCY #define USE_LIGHT +#define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_RESUME_EFFECT +#define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK #define USE_LOGGER #define USE_LOGGER_LEVEL_LISTENERS @@ -136,6 +142,50 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define NFC_TAG_LISTENER_COUNT 1 +#define PN532_BINARY_SENSOR_COUNT 1 +#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN532_ON_TAG_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_TRIGGER_COUNT 1 +#define REMOTE_BASE_DUMPER_COUNT 1 +#define REMOTE_BASE_LISTENER_COUNT 1 +#define USE_REMOTE_PROTOCOL_ABBWELCOME +#define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_BEO4 +#define USE_REMOTE_PROTOCOL_BRENNENSTUHL +#define USE_REMOTE_PROTOCOL_BYRONSX +#define USE_REMOTE_PROTOCOL_CANALSAT +#define USE_REMOTE_PROTOCOL_COOLIX +#define USE_REMOTE_PROTOCOL_DISH +#define USE_REMOTE_PROTOCOL_DOOYA +#define USE_REMOTE_PROTOCOL_DRAYTON +#define USE_REMOTE_PROTOCOL_DYSON +#define USE_REMOTE_PROTOCOL_GOBOX +#define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_JVC +#define USE_REMOTE_PROTOCOL_KEELOQ +#define USE_REMOTE_PROTOCOL_LG +#define USE_REMOTE_PROTOCOL_MAGIQUEST +#define USE_REMOTE_PROTOCOL_MIDEA +#define USE_REMOTE_PROTOCOL_MIRAGE +#define USE_REMOTE_PROTOCOL_NEC +#define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PRONTO +#define USE_REMOTE_PROTOCOL_RAW +#define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC6 +#define USE_REMOTE_PROTOCOL_RC_SWITCH +#define USE_REMOTE_PROTOCOL_ROOMBA +#define USE_REMOTE_PROTOCOL_SAMSUNG +#define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SONY +#define USE_REMOTE_PROTOCOL_SYMPHONY +#define USE_REMOTE_PROTOCOL_TOSHIBA_AC +#define USE_REMOTE_PROTOCOL_TOTO +#define SYSTA_BUS_LISTENER_COUNT 1 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER @@ -180,6 +230,7 @@ #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY +#define USE_SERIAL_PROXY_TAP #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR @@ -227,7 +278,7 @@ #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL -#define USE_IMPROV_SERIAL_NEXT_URL +#define USE_IMPROV_NEXT_URL #define USE_MD5 #define USE_NOISE #define USE_SHA256 @@ -244,7 +295,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_ENCRYPTION -#define USE_OTA_ENCRYPTION_FROM_API #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD @@ -259,6 +309,8 @@ #ifdef USE_ARDUINO #define USE_PROMETHEUS #define USE_WIFI_WPA2_EAP +// Kept in the Arduino block so clang-tidy sees both scan storage paths +#define USE_WIFI_MULTI_SSID #endif // Platforms with native 64-bit time sources (no rollover tracking needed) @@ -290,12 +342,15 @@ #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif +// Defined here so static analysis parses the slot and its call site. +#define USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #endif // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_VASPRINTF_STUB #define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER @@ -348,8 +403,7 @@ #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C -#define USE_IMPROV -#define USE_ESP32_IMPROV_NEXT_URL +#define USE_IMPROV_BLE #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN @@ -357,6 +411,7 @@ #define USE_SENDSPIN_CONTROLLER #define USE_SENDSPIN_METADATA #define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_SWITCH #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS @@ -418,8 +473,10 @@ #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 #define USE_ETHERNET_CH390 +#define USE_ETHERNET_KSZ8851SNL #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 +#define CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 #define USE_ETHERNET_MANUAL_IP #define USE_ETHERNET_IP_STATE_LISTENERS @@ -435,6 +492,9 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#ifndef USE_OPENTHREAD +#define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#endif #endif #if defined(USE_ESP32_VARIANT_ESP32S2) @@ -490,7 +550,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 1) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -567,6 +627,7 @@ #define USE_NRF52_REG0_VOUT 5 #define USE_NRF52_UICR_ERASE #define USE_OTA_ROLLBACK +#define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 #define USE_ZIGBEE @@ -582,7 +643,6 @@ #define USE_DASHBOARD_IMPORT // Default counts for static analysis -#define CONTROLLER_REGISTRY_MAX 2 #define ESPHOME_AREA_COUNT 10 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 21a5fc3706c..dc27c1e56a2 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } +void EntityBase::set_internal(bool internal) { + // Remove the after-setup path in 2027.3.0 and ignore the call instead. + if (App.is_setup_complete()) { + ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", + this->get_name().c_str()); + } + this->flags_.internal = internal; +} + // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index f38e30bf52d..8796e9f067a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -88,13 +88,26 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients - // are NOT notified of the change, the flag may have already been read during setup, and there - // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. - ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " - "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", - "2026.3.0") - void set_internal(bool internal) { this->flags_.internal = internal; } + // Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key + // whenever possible: it is guaranteed and has none of the limitations below. Use this only when + // the decision can only be made at boot. Must be called before MQTT and the API read the flag: + // from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI. + // If the answer comes from a device handshake, hold setup with can_proceed() until it arrives. + // Calls after setup finishes are undefined behavior: the flag is still written and an error is + // logged, and from 2027.3.0 the call will be ignored. + // + // Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM + // or performance cost would be considered. + // - No consumer is notified of a change, so the flag can only be decided once per boot. + // - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority, + // or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT + // (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was + // stalled on a slow component has already listed the entities, so they keep the old value. + // - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that + // codegen runs for exposed entities, so a name collision can surface at runtime. Entities with + // only an 'id:' are forced internal and use the id as their name. + // - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee. + void set_internal(bool internal); // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 54e2551cb4e..4f446050253 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,7 +1,8 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import functools import logging +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -32,7 +33,7 @@ from esphome.helpers import ( sanitize, snake_case, ) -from esphome.types import ConfigType, EntityMetadata +from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType _LOGGER = logging.getLogger(__name__) @@ -467,6 +468,34 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx +@dataclass(frozen=True, slots=True) +class SubEntities: + """Create the child entity configured under key, set parent (if bound) and pass it to setter. + + Extra arguments go to new_entity. Returns None only when key is absent, so the result can be + used directly as a condition. + """ + + new_entity: Callable[..., Awaitable[MockObj]] + config: ConfigType + parent: MockObj | ID | None = None + + async def __call__( + self, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, + **kwargs: Any, + ) -> MockObj | None: + if (conf := self.config.get(key)) is None: + return None + var = await self.new_entity(conf, *args, **kwargs) + if self.parent is not None: + await cg.register_parented(var, self.parent) + add(setter(var)) + return var + + def inherit_property_from(property_to_inherit, parent_id_property, transform=None): """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. If a property is already set, it will not be inherited. diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 433d2547b0e..313daacbd0f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -20,6 +20,15 @@ namespace esphome { +#ifdef USE_ESP8266 +std::string progmem_string(ProgmemStr str) { + auto *src = reinterpret_cast(str); + std::string result(strlen_P(src), '\0'); + memcpy_P(result.data(), src, result.size()); + return result; +} +#endif + static const char *const TAG = "helpers"; __attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a0afb03124e..6d00e187999 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,16 +5,20 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include #include #include #include @@ -38,6 +42,7 @@ #endif #ifdef USE_ESP32 +#include #include #endif @@ -237,8 +242,9 @@ template class StaticVector { size_t count_{0}; public: - // Default constructor - StaticVector() = default; + // User provided, not "= default": otherwise `StaticVector<...> x_{}` members + // value-initialize and memset data_, defeating the comment above. + constexpr StaticVector() noexcept {} // Iterator range constructor template StaticVector(InputIt first, InputIt last) { @@ -270,6 +276,9 @@ template class StaticVector { // Clear all elements void clear() { count_ = 0; } + // Set the element count, capped at N. Elements are neither initialized when growing nor destroyed when + // shrinking; release owning elements before shrinking past them. + void resize(size_t n) { count_ = n < N ? n : N; } // Assign from iterator range template void assign(InputIt first, InputIt last) { @@ -539,7 +548,15 @@ template inline void init_array_from(std::array &des } } -/// Fixed-capacity vector - allocates once at runtime, never reallocates +// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped +// before it can land in rodata, which is RAM on ESP8266 +#ifdef USE_ESP32 +#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason) +#else +#define ESPHOME_ABORT_WITH_REASON(reason) abort() +#endif + +/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time template class FixedVector { @@ -562,8 +579,7 @@ template class FixedVector { void cleanup_() { if (data_ != nullptr) { destroy_elements_(); - // Free raw memory - ::operator delete(data_); + free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } } @@ -632,16 +648,27 @@ template class FixedVector { // Allocate capacity - can be called multiple times to reinit // IMPORTANT: After calling init(), you MUST use push_back() to add elements. // Direct assignment via operator[] does NOT update the size counter. + // Aborts on exhaustion; use try_init() to handle failure. void init(size_t n) { + if (!try_init(n)) + ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory"); + } + + // Same as init(), but returns false when memory is exhausted; the previous storage is freed either way + bool try_init(size_t n) { cleanup_(); reset_(); - if (n > 0) { - // Allocate raw memory without calling constructors - // sizeof(T) is correct here for any type T (value types, pointers, etc.) - // NOLINTNEXTLINE(bugprone-sizeof-expression) - data_ = static_cast(::operator new(n * sizeof(T))); - capacity_ = n; - } + if (n == 0) + return true; + if (n > SIZE_MAX / sizeof(T)) + return false; // the byte count would wrap into a small block + // sizeof(T) is correct here for any type T (value types, pointers, etc.) + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + data_ = static_cast(malloc(n * sizeof(T))); + if (data_ == nullptr) + return false; + capacity_ = n; + return true; } // Clear the vector (destroy all elements, reset size to 0, keep capacity) @@ -738,14 +765,22 @@ template class FixedVector { template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { + static_assert(std::is_trivially_default_constructible_v && std::is_trivially_destructible_v, + "the heap fallback leaves elements unconstructed"); if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new T[size]; + if (size <= SIZE_MAX / sizeof(T)) { + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + this->heap_buffer_ = static_cast(malloc(size * sizeof(T))); + } + // Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces + if (this->heap_buffer_ == nullptr) + ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory"); this->buffer_ = this->heap_buffer_; } } - ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + ~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc) // Delete copy and move operations to prevent double-delete SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; @@ -2034,6 +2069,32 @@ class LwIPLock { #endif }; +#if defined(USE_ESP8266) && F_CPU != 160000000L +// Forward decl from +// NOLINTNEXTLINE(readability-redundant-declaration) +extern "C" bool system_update_cpu_freq(uint8_t freq); +#endif + +/** Runs the CPU at 160 MHz while alive. ESP8266 built for 80 MHz only; elsewhere it compiles to nothing. + * + * The core resets the clock before every loop() pass, so a scope must stay within one pass, must not nest and + * must not yield to the main loop. Peripheral clocks are unchanged, but the cycle counter runs twice as fast, so + * code that times itself against F_CPU, including ISRs that fire while a scope is open, must read CPU2X. + */ +class CpuFrequencyBoost { + public: + CpuFrequencyBoost(const CpuFrequencyBoost &) = delete; + CpuFrequencyBoost &operator=(const CpuFrequencyBoost &) = delete; +#if defined(USE_ESP8266) && F_CPU != 160000000L + CpuFrequencyBoost() { system_update_cpu_freq(160); } + ~CpuFrequencyBoost() { system_update_cpu_freq(80); } +#else + // Not = default, so clang-tidy does not flag unused variables at call sites + CpuFrequencyBoost() {} + ~CpuFrequencyBoost() {} +#endif +}; + /** Helper class to request `loop()` to be called as fast as possible. * * Usually the ESPHome main loop runs at 60 Hz, sleeping in between invocations of `loop()` if necessary. When a higher @@ -2095,6 +2156,10 @@ void delay_microseconds_safe(uint32_t us); /// @name Memory management ///@{ +template struct RAMDeleter; +/// unique_ptr over RAMAllocator storage +template using RAMUniquePtr = std::unique_ptr>; + /** An STL allocator that uses SPI or internal RAM. * Returns `nullptr` in case no memory is available. * @@ -2165,6 +2230,26 @@ template class RAMAllocator { free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } + /// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead. + /// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it. + template RAMUniquePtr make_unique(Args &&...args) { + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + T *p = this->allocate(1); + if (p == nullptr) + return {}; + // ::new so a class scoped operator new cannot hide the global placement form + return RAMUniquePtr(::new (p) T(std::forward(args)...)); + } + + /// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0 + RAMUniquePtr make_unique_array_for_overwrite(size_t n) { + static_assert(std::is_trivially_default_constructible_v, "elements are left unconstructed"); + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + if (n == 0 || n > SIZE_MAX / sizeof(T)) + return {}; + return RAMUniquePtr(this->allocate(n)); + } + /** * Return the total heap space available via this allocator */ @@ -2227,6 +2312,19 @@ template class RAMAllocator { template using ExternalRAMAllocator = RAMAllocator; +/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned +template struct RAMDeleter { + void operator()(T *p) const { + p->~T(); + RAMAllocator().deallocate(p, 1); + } +}; +/// Array form: elements must be trivial, the count is not stored so only the storage is freed +template struct RAMDeleter { + static_assert(std::is_trivially_destructible_v, "RAMUniquePtr is for trivially destructible elements"); + void operator()(T *p) const { RAMAllocator().deallocate(p, 1); } +}; + /** * Functions to constrain the range of arithmetic values. */ diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 9fcddfeff61..2f1d5687588 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -36,7 +36,7 @@ void HOT esp_log_printf_(int level, const char *tag, int line, const char *forma #endif } -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void HOT esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...) { #ifdef USE_LOGGER ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); diff --git a/esphome/core/log.h b/esphome/core/log.h index 14d24412ef6..362ed83b915 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -16,7 +16,7 @@ #include #include -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #include "WString.h" #endif @@ -63,7 +63,8 @@ namespace esphome { void esp_log_printf_(int level, const char *tag, int line, const char *format, ...) // NOLINT __attribute__((format(printf, 4, 5))); -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 +// NOLINTNEXTLINE(readability-identifier-naming) void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT @@ -71,7 +72,7 @@ void esp_log_vprintf_(int level, const char *tag, int line, const char *format, int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #define ESPHOME_LOG_FORMAT(format) F(format) #else #define ESPHOME_LOG_FORMAT(format) format @@ -172,7 +173,7 @@ int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT // Helper class that identifies strings that may be stored in flash storage (similar to Arduino's __FlashStringHelper) struct LogString; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #include @@ -181,7 +182,7 @@ struct LogString; #define LOG_STR(s) (reinterpret_cast(PSTR(s))) #define LOG_STR_LITERAL(s) LOG_STR_ARG(LOG_STR(s)) -#else // !USE_STORE_LOG_STR_IN_FLASH +#else // !USE_ESP8266 #define LOG_STR(s) (reinterpret_cast(s)) #define LOG_STR_ARG(s) (reinterpret_cast(s)) diff --git a/esphome/core/pch_prefix.h b/esphome/core/pch_prefix.h new file mode 100644 index 00000000000..fbb4eaf79f7 --- /dev/null +++ b/esphome/core/pch_prefix.h @@ -0,0 +1,5 @@ +#pragma once +// The core headers the precompiled header holds +#include "esphome/core/defines.h" +#include "esphome/core/application.h" +#include "esphome/core/automation.h" diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index d349418d02e..54fc42dde16 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "esphome/core/hal.h" // For PROGMEM definition @@ -23,19 +24,13 @@ #define ESPHOME_strcasecmp_P strcasecmp_P #define ESPHOME_strncmp_P strncmp_P #define ESPHOME_strncasecmp_P strncasecmp_P +#define ESPHOME_strlen_P strlen_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; -// Storage class for PROGMEM_STRING_TABLE data. Mirrors the logger's choice of -// LOG_STR_ARG: when LOG_STR_ARG treats the LogString as PROGMEM (PGM_P), the -// table data must actually be in flash; when LOG_STR_ARG treats it as a plain -// const char* (assumes RAM), the table data must live in RAM or non-logger -// consumers (ArduinoJson, Print, MQTT publish) crash on unaligned flash reads. -#ifdef USE_STORE_LOG_STR_IN_FLASH +// Storage class for PROGMEM_STRING_TABLE data; LOG_STR_ARG treats a LogString as PGM_P +// on ESP8266, so the table data must be in flash to match. #define ESPHOME_PROGMEM_STRING_TABLE_STORAGE PROGMEM #else -#define ESPHOME_PROGMEM_STRING_TABLE_STORAGE -#endif -#else #define ESPHOME_F(string_literal) (string_literal) #define ESPHOME_PGM_P const char * #define ESPHOME_PSTR(s) (s) @@ -46,6 +41,7 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strcasecmp_P strcasecmp #define ESPHOME_strncmp_P strncmp #define ESPHOME_strncasecmp_P strncasecmp +#define ESPHOME_strlen_P strlen // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; // No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. @@ -54,6 +50,13 @@ using ProgmemStr = const char *; namespace esphome { +/// Copies a string stored with ESPHOME_F into a std::string. +#ifdef USE_ESP8266 +std::string progmem_string(ProgmemStr str); +#else +inline std::string progmem_string(ProgmemStr str) { return std::string(str); } +#endif + /// Helper for C++20 string literal template arguments template struct FixedString { char data[N]{}; diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 4cfead44c29..87629c2507d 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -14,11 +14,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size } if (this->stack_buffer_ != nullptr && (stack_size > this->stack_size_ || use_psram != this->use_psram_)) { - // Existing buffer is too small or wrong memory type; deallocate to reallocate below - RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL - : RAMAllocator::ALLOC_INTERNAL); - allocator.deallocate(this->stack_buffer_, this->stack_size_); - this->stack_buffer_ = nullptr; + // Existing buffer is too small or wrong memory type; free it to reallocate below + this->stack_buffer_.reset(); } if (this->stack_buffer_ == nullptr) { @@ -26,13 +23,23 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size this->use_psram_ = use_psram; RAMAllocator allocator(use_psram ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); - this->stack_buffer_ = allocator.allocate(stack_size); + this->stack_buffer_ = allocator.make_unique_array_for_overwrite(stack_size); } if (this->stack_buffer_ == nullptr) { return false; } - this->handle_ = xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_, &this->tcb_); + if (this->tcb_ == nullptr) { + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->tcb_ = allocator.make_unique(); + } + if (this->tcb_ == nullptr) { + this->deallocate(); + return false; + } + + this->handle_ = + xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); if (this->handle_ == nullptr) { this->deallocate(); return false; @@ -40,23 +47,35 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return true; } -void StaticTask::destroy() { - if (this->handle_ != nullptr) { - TaskHandle_t handle = this->handle_; - this->handle_ = nullptr; - vTaskDelete(handle); +bool StaticTask::destroy() { + if (this->handle_ == nullptr) { + return true; } + + // Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks + // the other core to yield though, so the task may still be running on it for a moment. + vTaskSuspend(this->handle_); + if (eTaskGetState(this->handle_) != eSuspended) { + // The task is still running on the other core and using its stack. Deleting it now would only put it on + // the termination list and return, so the caller has to try again once it has been swapped out. + return false; + } + + // The task cannot run again, so the delete completes right away instead of being left to the idle task. + TaskHandle_t handle = this->handle_; + this->handle_ = nullptr; + vTaskDelete(handle); + return true; } -void StaticTask::deallocate() { - this->destroy(); - if (this->stack_buffer_ != nullptr) { - RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL - : RAMAllocator::ALLOC_INTERNAL); - allocator.deallocate(this->stack_buffer_, this->stack_size_); - this->stack_buffer_ = nullptr; - this->stack_size_ = 0; +bool StaticTask::deallocate() { + if (!this->destroy()) { + return false; } + this->stack_buffer_.reset(); + this->stack_size_ = 0; + this->tcb_.reset(); + return true; } } // namespace esphome diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 5fd5b38f9ef..10c55f70c62 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -5,42 +5,58 @@ #include #include +#include "esphome/core/helpers.h" + #include namespace esphome { /** Helper for FreeRTOS static task management. - * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Stores the TCB in internal RAM and manages the stack buffer with create/destroy methods. + * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: + StaticTask() = default; + // Non-copyable, non-movable — the task handle and its buffers are not transferable + StaticTask(const StaticTask &) = delete; + StaticTask &operator=(const StaticTask &) = delete; + StaticTask(StaticTask &&) = delete; + StaticTask &operator=(StaticTask &&) = delete; + /// @brief Check if the task has been created and not yet destroyed. bool is_created() const { return this->handle_ != nullptr; } /// @brief Get the FreeRTOS task handle. TaskHandle_t get_handle() const { return this->handle_; } - /// @brief Allocate stack and create task. + /// @brief Allocate the TCB (always internal RAM) and the stack, then create the task. /// @param fn Task function /// @param name Task name (for debug) - /// @param stack_size Stack size in StackType_t words + /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM - /// @return true on success + /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call. - void destroy(); + /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. + /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is + /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. + /// @return true if the task was deleted; false if it is still running on another core, in which case the + /// caller should try again later. + bool destroy(); - /// @brief Delete the task (if running) and free the stack buffer. - void deallocate(); + /// @brief Delete the task (if created) and free the TCB and stack buffer. + /// @return true if the buffers were freed; false if the task is still running on another core, in + /// which case the caller should try again later. + bool deallocate(); protected: TaskHandle_t handle_{nullptr}; - StaticTask_t tcb_; - StackType_t *stack_buffer_{nullptr}; + RAMUniquePtr tcb_; + RAMUniquePtr stack_buffer_; uint32_t stack_size_{0}; bool use_psram_{false}; }; diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 2c7ec914c7b..4c0ce28a5ac 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -22,6 +22,10 @@ namespace esphome { * pointer. When it is default constructed, it has empty string. You can freely copy or move around this struct, but * never free its pointer. str() function can be used to export the content as std::string. StringRef is adopted from * + * + * A StringRef may carry a null pointer while its length is zero (the generated api messages start their encode only + * string fields that way). Every member treats that as the empty string: the iterators form an empty range, and + * c_str() and byte() return the null pointer, so callers that print or copy through those must check empty() first. */ class StringRef { public: @@ -78,7 +82,7 @@ class StringRef { /// True if the view begins with the given prefix (std::string::starts_with-like) bool starts_with(const StringRef &prefix) const { - return len_ >= prefix.len_ && std::memcmp(base_, prefix.base_, prefix.len_) == 0; + return len_ >= prefix.len_ && (prefix.len_ == 0 || std::memcmp(base_, prefix.base_, prefix.len_) == 0); } bool starts_with(const char *prefix) const { return this->starts_with(StringRef(prefix)); } bool starts_with(const std::string &prefix) const { return this->starts_with(StringRef(prefix)); } @@ -92,14 +96,15 @@ class StringRef { return actual; } - std::string str() const { return std::string(base_, len_); } + std::string str() const { return std::string(base_, len_); } // fine for {nullptr, 0}: nothing is read const uint8_t *byte() const { return reinterpret_cast(base_); } operator std::string() const { return str(); } /// Compare (compatible with std::string::compare) int compare(const StringRef &other) const { - int result = std::memcmp(base_, other.base_, std::min(len_, other.len_)); + size_type common = std::min(len_, other.len_); + int result = common == 0 ? 0 : std::memcmp(base_, other.base_, common); if (result != 0) return result; if (len_ < other.len_) @@ -222,6 +227,11 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { namespace internal { // NOLINTBEGIN(google-runtime-int) template inline R parse_number(const StringRef &str, size_t *pos, F conv) { + if (str.empty()) { // nothing to parse, and a null view must not reach the C library + if (pos) + *pos = 0; + return R{}; + } char *end; R result = conv(str.c_str(), &end); // Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number @@ -230,6 +240,11 @@ template inline R parse_number(const StringRef &str, siz return result; } template inline R parse_number(const StringRef &str, size_t *pos, int base, F conv) { + if (str.empty()) { // nothing to parse, and a null view must not reach the C library + if (pos) + *pos = 0; + return R{}; + } char *end; R result = conv(str.c_str(), &end, base); // Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number @@ -258,7 +273,16 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) -inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } +inline void convertToJson(const StringRef &src, JsonVariant dst) { + // Bounded by the view length; a null, empty view becomes "" rather than JSON null + if (src.empty()) { + dst.set(""); + return; + } + // The explicit isStatic is load bearing: without it clang-tidy reads size() as the bool of the two argument + // constructor and asks for empty() instead + dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); +} #endif // USE_JSON } // namespace esphome diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 3ce94cc9791..8a825362270 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -45,6 +45,7 @@ the last `yield` expression defines what is returned. from __future__ import annotations from collections.abc import Awaitable, Callable, Generator, Iterator +import contextvars import enum import functools import heapq @@ -277,14 +278,22 @@ class _Task: id_number: int, iterator: Iterator[None], original_function: Any, + context: contextvars.Context, ): self.priority = priority self.id_number = id_number self.iterator = iterator self.original_function = original_function + self.context = context def with_priority(self, priority: float) -> _Task: - return _Task(priority, self.id_number, self.iterator, self.original_function) + return _Task( + priority, + self.id_number, + self.iterator, + self.original_function, + self.context, + ) @property def _cmp_tuple(self) -> tuple[float, int]: @@ -321,7 +330,10 @@ class FakeEventLoop: coro = coroutine(func) gen = coro(*args, **kwargs) prio = getattr(coro, "priority", 0.0) - task = _Task(prio, self._task_counter, gen, func) + # Each task gets its own copy of the current context, isolating any + # contextvars it sets from other tasks the scheduler interleaves it with + # (mirrors what asyncio.Task does internally). + task = _Task(prio, self._task_counter, gen, func, contextvars.copy_context()) self._task_counter += 1 heapq.heappush(self._pending_tasks, task) @@ -352,7 +364,7 @@ class FakeEventLoop: ) try: - next(task.iterator) + task.context.run(next, task.iterator) # Decrease priority over time, so that if this task is blocked # due to a dependency others will clear the dependency # This could be improved with a less naive approach diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e6b8c0de42c..b0c3533e040 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,7 +19,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last +from esphome.helpers import cpp_string_escape, indent, indent_all_but_first_and_last from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase @@ -670,6 +670,28 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": return Pvariable(id_, rhs) +def static_function( + name: str, + return_type: SafeExpType, + parameters: TemplateArgsType, + body: list[str], +) -> RawExpression: + """Emit ``static (parameters) { body }`` at global scope and return an + expression naming it, for use as a template argument or a function pointer. + + Every id the body names must already be declared, which holds when the statements were + rendered through ``get_variable`` or ``process_lambda``. + """ + params = ParameterListExpression(*parameters) + add_global( + RawStatement( + f"static {safe_exp(return_type)} {name}({params}) {{\n" + f"{indent(chr(10).join(body))}\n}}" + ) + ) + return RawExpression(name) + + def add(expression: Expression | Statement, prepend: bool = False): """Add an expression to the codegen section. @@ -1187,3 +1209,48 @@ class MockObjClass(MockObj): def __repr__(self): return f"MockObjClass<{str(self.base)}, parents={self._parents}>" + + +class StaticCastExpression(Expression): + __slots__ = ("type", "exp") + + def __init__(self, type: Any, exp: SafeExpType): + self.type = str(type) + self.exp = safe_exp(exp) + + def __str__(self): + return f"static_cast<{self.type}>({self.exp})" + + +def call_lambda(lamb: LambdaExpression) -> Expression: + """ + Given a lambda, either reduce to a simple expression or call it, possibly with parameters + from the surrounding context. + This is for use only with value-returning lambdas, used in places where the value of a lambda call is needed. + :param lamb: The LambdaExpression to call or reduce + :return: An Expression representing the result of calling the lambda or reducing it to a simple expression + """ + # Developer error if this is called with a lambda that doesn't have a return type + assert lamb.return_type is not None, "Lambda must have a return type to be called" + expr = lamb.content.strip() + # A lone `return ;` reduces to the expression; anything longer is called as is. + # A braced return such as `return {};` needs the lambda's return type, so it is called. + if ( + re.match(r"^return\b", expr) + and expr.endswith(";") + and expr.count(";") == 1 + and not expr[6:].lstrip().startswith("{") + ): + expr = RawExpression(expr[6:-1].strip()) + # Don't cast if the return type is a class + if isinstance(lamb.return_type, MockObjClass): + return expr + return StaticCastExpression(lamb.return_type, expr) + # If lambda has parameters, call it with their names + # Parameter names come from hardcoded component code (like "x", "it", "event") + # not from user input, so they're safe to use directly + if lamb.parameters and lamb.parameters.parameters: + return CallExpression( + lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] + ) + return CallExpression(lamb) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 53b59cb1240..fc44d27f472 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from dataclasses import dataclass, field import logging @@ -142,9 +142,10 @@ _SLOT_COUNTER_DOMAIN = "slot_counter" @dataclass class _SlotCounterState: - """Per-run slot counter state: requested counts and already-emitted defines.""" + """Per-run slot counter state: requested counts per define and key, and + already-emitted defines.""" - counts: dict[str, int] = field(default_factory=dict) + counts: dict[str, dict[Hashable, int]] = field(default_factory=dict) emitted: set[str] = field(default_factory=set) @@ -156,11 +157,13 @@ def _get_slot_counter_state() -> _SlotCounterState: def get_slot_count(define: str) -> int: - """Number of slots requested so far for `define`.""" - return _get_slot_counter_state().counts.get(define, 0) + """Value `define` would be emitted with so far: the largest count requested + under any one key, which is the plain request count when no key is used.""" + counts = _get_slot_counter_state().counts.get(define) + return max(counts.values()) if counts else 0 -def slot_counter(define: str) -> Callable[[], None]: +def slot_counter(define: str) -> Callable[..., None]: """Create a request_slot function for codegen-sized storage. The pattern behind a StaticVector listener array: a consumer's to_code @@ -169,6 +172,11 @@ def slot_counter(define: str) -> Callable[[], None]: emitted with the requested count. No requests, no define: the guarded storage and its registration method compile out entirely. + When several objects each declare the storage at the same size (one list + per receiver, per hub, ...) the caller passes the owning object as `key` + and the define becomes the largest count any one key requested, not the + total. Requests without a key share one count. + The counts live in a table under CORE.data, which clears between runs. A request arriving after the define was already emitted raises instead of silently undercounting: the define would keep the stale smaller value and @@ -179,10 +187,10 @@ def slot_counter(define: str) -> Callable[[], None]: async def emit_job() -> None: state = _get_slot_counter_state() state.emitted.add(define) - # Scheduled only by the first request, so the count is always >= 1 here. - add_define(define, state.counts[define]) + # Scheduled only by the first request, so there is at least one count here. + add_define(define, max(state.counts[define].values())) - def request_slot() -> None: + def request_slot(key: Hashable = None) -> None: state = _get_slot_counter_state() if define in state.emitted: raise ValueError( @@ -190,10 +198,16 @@ def slot_counter(define: str) -> Callable[[], None]: f"define was emitted; request slots from to_code, not from a " f"job running after FINAL emission" ) - counts = state.counts - counts[define] = (count := counts.get(define, 0) + 1) - if count == 1: + counts = state.counts.get(define) + if counts is None: + counts = state.counts[define] = {} CORE.add_job(emit_job) + elif (key is None) != (None in counts): + # a keyed and an unkeyed request would compare buckets instead of adding up + raise ValueError( + f"slot_counter('{define}'): every request must use a key, or none of them" + ) + counts[key] = counts.get(key, 0) + 1 return request_slot diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 45d6559b3f4..04892f5feaa 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -37,7 +37,6 @@ optional = global_ns.namespace("std").class_("optional") arduino_json_ns = global_ns.namespace("ArduinoJson") JsonObject = arduino_json_ns.class_("JsonObject") JsonObjectConst = arduino_json_ns.class_("JsonObjectConst") -Controller = esphome_ns.class_("Controller") GPIOPin = esphome_ns.class_("GPIOPin") InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin) gpio_ns = esphome_ns.namespace("gpio") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 61171cf5732..d6260226345 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -11,7 +11,7 @@ running codegen on a config, it generates a minimal ESP-IDF CMake project: component, so their public include dirs land on the translation unit; * the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...). -then runs ``idf.py reconfigure`` (configure only, no compile) and reads the +then runs the CMake configure (no compile) and reads the resulting ``build/compile_commands.json``. The IDF version is the esp32 component's recommended version. @@ -380,7 +380,7 @@ def _write_tidy_project( def _generate_compile_commands( work_dir: Path, settings: _Settings, platformio_ini: Path ) -> Path: - """Generate the tidy project and run ``idf.py reconfigure`` (no build). + """Generate the tidy project and run the CMake configure (no build). Two-phase, like a real ESPHome build: a first configure with no builtin requires discovers which components actually register for the target (e.g. @@ -411,7 +411,7 @@ def _generate_compile_commands( # Phase 1: discover the components available for this target. _write_tidy_project(work_dir, [], extra_deps, settings) if toolchain.run_reconfigure() != 0: - raise RuntimeError("idf.py reconfigure (discovery) failed") + raise RuntimeError("ESP-IDF CMake configure (discovery) failed") requires = sorted( set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS @@ -420,7 +420,7 @@ def _generate_compile_commands( # Phase 2: require every available builtin component. _write_tidy_project(work_dir, requires, extra_deps, settings) if toolchain.run_reconfigure() != 0: - raise RuntimeError("idf.py reconfigure failed") + raise RuntimeError("ESP-IDF CMake configure failed") return work_dir / "build" / "compile_commands.json" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 9373b5f5691..8c377561ca6 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -16,6 +16,7 @@ from esphome.build_helpers.ccache import ( parse_enable_env, 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.core import Version from esphome.framework_helpers import ( @@ -33,7 +34,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, tool_version_runs, - warn_prefetch_failures, + warn_batch_failures, ) from esphome.helpers import write_file_if_changed @@ -773,7 +774,7 @@ def _prefetch_idf_tool_archives( for entry in entries ], ) - warn_prefetch_failures(failures) + warn_batch_failures(failures, "Could not prefetch %s: %s") if len(failures) == len(entries): # A systematic fault, not one flaky mirror: the resume # workaround (#17703) is off for this whole install @@ -1210,37 +1211,37 @@ def _ccache_env() -> dict[str, str]: # ESPHOME_CCACHE_ENABLE. idf_knob = parse_enable_env("IDF_CCACHE_ENABLE") if idf_knob is 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 + # Replace the inherited raw value (e.g. "disable") with the canonical + # off spelling, so every reader of the env sees the same answer return {"IDF_CCACHE_ENABLE": "0"} if idf_knob is True: # Forced on ignores the runnability verdict, but the outcome is # worth saying out loud. Probed directly (not via the resolver, # whose failure message says "compiling without ccache" -- exactly # what forced-on does NOT do): only the truly-missing case means - # idf.py compiles without ccache; a broken binary is still used, - # since idf.py does its own PATH lookup. + # the build compiles without ccache; a broken binary is still used, + # since IDF's CMake does its own PATH lookup. if (ccache := shutil.which("ccache")) is None: _LOGGER.warning( "IDF_CCACHE_ENABLE=1 but no ccache binary is on PATH; " - "idf.py will compile without ccache" + "the build will compile without ccache" ) else: # The probe warns with this message iff the binary fails tool_version_runs( ccache, "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; the build will use it anyway", ) elif resolve_ccache_path() is 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 + # probe-rejected ccache CMake 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 + env.update(ccache_pch_env()) + # Exactly one canonical spelling ever reaches the build, whatever the # accepted input spelling was ("enable", "yes", ...) env["IDF_CCACHE_ENABLE"] = "1" return env diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py deleted file mode 100644 index 7ed11d75543..00000000000 --- a/esphome/espidf/runner.py +++ /dev/null @@ -1,308 +0,0 @@ -r"""Subprocess entry point for running ``idf.py`` with stdio wrapping. - -Invoked as ``python runner.py [script args...]``. - -Wraps ``sys.stdout`` and ``sys.stderr`` with a ``_FilteringTTYStream`` -shim so that: - -1. ``isatty()`` unconditionally returns True. CMake, Ninja, and idf.py's - own progress-bar code all check ``stream.isatty()`` to decide between - TTY-format output (``\\r`` cursor moves, ANSI colors, fancy progress - bars) and a plain fallback. With the wrapper in place they always - emit TTY format, even when our real stdout is a pipe to the parent - process (e.g. running under the Home Assistant dashboard add-on). - Downstream consumers — local terminals and the HA dashboard log - viewer — render the TTY control sequences correctly. - -2. ``FILTER_IDF_LINES`` is applied inside the shim's ``write()`` so - noisy idf.py output is dropped before it leaves this subprocess. - Filtering is skipped when ``-v`` / ``--verbose`` appears in argv so - verbose mode still shows everything. - -ESP-IDF runs under its own Python virtual environment which does not -have the ``esphome`` package installed, so the runner is intentionally -self-contained: no imports from ``esphome`` at all. The line-filtering -wrapper is inlined below rather than imported from -``esphome.util.RedirectText`` for that reason. -""" - -import sys - -# Regex patterns matched against each line of idf.py / CMake / Ninja -# output. Lines that match are dropped before reaching the parent -# process. Patterns are anchored at the start of the line (the shim -# uses ``re.match``). Disabled when the user passes ``-v`` / -# ``--verbose`` to ``esphome compile``. -FILTER_IDF_LINES: list[str] = [ - # idf.py's "how to flash" block at the end of a successful build. - # ESPHome handles flashing itself, so these instructions just clutter - # the output. - r"Project build complete\.", - r" idf\.py ", - r" python -m esptool ", - r"or$", - r"or from the ", - # CMake dumps the full list of IDF component paths on one giant line. - # It's purely informational and bloats the log. - r"-- Component paths:", - # CMake lists every linker script it adds (dozens of lines) and the - # complete flat list of IDF components on one giant line. Neither - # has diagnostic value for end users. - r"-- Adding linker script ", - r"-- Components:", - # IDF component manager notices: emitted on first build (no lock), - # once per stubbed dependency, plus the final "Processing N - # dependencies" enumeration. Patterns allow a leading run of dots - # because the component manager prints progress dots on the same - # line, so a NOTICE often arrives prefixed with ".NOTICE:" or - # "...........NOTICE:". - r"\.*NOTICE: ", - # ``idf.py size`` prefaces its table with a centered banner; the - # per-region table below already makes the structure obvious. - r"\s*Memory Type Usage Summary", - # Prefix match for esp-idf-size's trailing "Note:" paragraph (no - # upstream flag suppresses it). - r"Note: The reported total sizes may be smaller than those in the", - # Drop the blank line rich emits after the note so the build log - # doesn't end with an orphan gap before ESPHome's own status lines. - r"\s*$", - # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; - # esphome-libs strips ``.git`` from the tarball so those probes fail - # noisily without affecting the build. - r"-- git rev-parse returned ", - r"fatal: not a git repository", - r"Stopping at filesystem boundary", -] - - -def main() -> int: - # ---- sys.path fix-up --------------------------------------------------- - # - # When Python runs this file as ``python runner.py``, it prepends the - # script's directory — ``/esphome/espidf/`` — to - # ``sys.path[0]``. That directory is part of the esphome package whose - # sibling ``types.py`` (in ``esphome/``) collides with stdlib ``types``. - # Any subsequent import that transitively touches ``types`` (``runpy``, - # ``pathlib``, ``functools``, ``typing``, ...) could resolve the wrong - # module. Drop the entry pre-emptively. ``sys`` is a built-in so - # importing it at module level earlier did not trigger the shadow. - if sys.path and sys.path[0]: - sys.path.pop(0) - # ---- end sys.path fix-up ----------------------------------------------- - - import contextlib - import os - from pathlib import Path - import re - import runpy - - # Patch ``os.get_terminal_size`` to return a fallback size instead - # of raising ``OSError`` when the underlying fd isn't a real - # terminal. - # - # idf.py's ``fit_text_in_terminal`` (in ``idf_py_actions/tools.py``) - # unconditionally calls ``os.get_terminal_size()`` to format ninja - # progress lines. When that raises ``[Errno 25] Inappropriate - # ioctl for device`` on our pipe-backed stdout, idf.py catches the - # exception as ``EnvironmentError`` and silently exits its stdout - # reader coroutine — dropping all ninja build output from that - # point on. Returning a valid value keeps the coroutine alive so - # progress and error lines continue to flow through to the parent - # process. - # - # Honour the ``COLUMNS`` / ``LINES`` env vars if the caller set - # them explicitly. Otherwise fall back to ``(0, 0)``, which - # ``fit_text_in_terminal`` treats as "unknown width, don't - # truncate" (see the ``if not terminal_width: return out`` guard). - # Downstream log viewers (local terminals, the HA dashboard) wrap - # or scroll long lines themselves, so we'd rather emit the full - # file path than have idf.py elide its middle. - _orig_get_terminal_size = os.get_terminal_size - - def _get_terminal_size_fallback(fd: int = 1) -> os.terminal_size: - try: - return _orig_get_terminal_size(fd) - except OSError: - try: - columns = int(os.environ.get("COLUMNS", "0")) - except ValueError: - columns = 0 - try: - lines = int(os.environ.get("LINES", "0")) - except ValueError: - lines = 0 - return os.terminal_size((columns, lines)) - - os.get_terminal_size = _get_terminal_size_fallback # type: ignore[assignment] - - # Strip ANSI escape sequences before comparing a line against the filter - # patterns, so colorized lines still match plain-text patterns. - ansi_escape = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") - - class _FilteringTTYStream: - r"""Minimal stdout/stderr wrapper. - - * ``isatty()`` unconditionally returns True, tricking downstream - code into emitting TTY-format output. - * Input is split with ``str.splitlines(keepends=True)``, which - breaks on more than ``\\n`` and ``\\r``; form feed and a few - other control characters count too. Any piece whose - ANSI-stripped, right-stripped form matches one of - ``filter_lines`` is dropped. - * Only the final piece can still be waiting for more text, so - that one is held until a ``\\n`` or ``\\r`` arrives. A piece - that ended on one of the other breaks goes out as it is. - - Mirrors the matching semantics of ``esphome.util.RedirectText`` - so filter patterns behave identically in both the PlatformIO - and IDF runner paths. - """ - - def __init__(self, stream, filter_lines: list[str] | None) -> None: - self._stream = stream - if filter_lines: - combined = r"|".join(r"(?:" + p + r")" for p in filter_lines) - self._filter_pattern: re.Pattern[str] | None = re.compile(combined) - else: - self._filter_pattern = None - self._line_buffer = "" - - def __getattr__(self, name: str): - # Hide ``buffer`` so consumers that use either - # ``getattr(stream, 'buffer', None)`` or - # ``hasattr(stream, 'buffer')`` see this as a text-only stream - # and skip writing raw bytes (which would bypass the filter). - if name == "buffer": - raise AttributeError(name) - return getattr(self._stream, name) - - def isatty(self) -> bool: - return True - - def flush(self) -> None: - self._stream.flush() - - def _emit(self, line: str) -> None: - if self._filter_pattern is not None: - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - return - self._stream.write(line) - - def drain(self) -> None: - """Write out a held-back line that never got its terminator. - - idf.py and CMake do not always end their last line with a - newline, and a build that dies part way through can stop mid - line. Without this the user is left staring at a build that - ended with no explanation. - """ - if not self._line_buffer: - return - line, self._line_buffer = self._line_buffer, "" - try: - # Add the terminator the line never got, so whatever ESPHome - # prints next does not run onto the same line. - self._emit(line + "\n") - self._stream.flush() - except (OSError, ValueError) as err: - # We are called from cleanup, so raising would replace the - # build's real exit code. Saying so must not raise either: - # under the dashboard our stdout and stderr are the same - # pipe, so whatever broke the write has most likely broken - # the report, and ``sys.__stderr__`` is None on some - # interpreters. Carry the line along; it is usually the - # message saying why the build failed. - if (real_stderr := sys.__stderr__) is not None: - with contextlib.suppress(OSError, ValueError): - print( - f"Could not write out remaining output ({err}): {line}", - file=real_stderr, - ) - - def write(self, data) -> int: - # Text streams normally hand us ``str``; decode in case - # somebody writes bytes directly. - if not isinstance(data, str): - data = data.decode(errors="replace") - - if self._filter_pattern is None: - # Nothing to match against, so no need to wait for a full line. - self._emit(data) - else: - lines = (self._line_buffer + data).splitlines(keepends=True) - # Every piece but the last ends with something - # ``str.splitlines`` treats as a break, so only the last one - # can still be waiting for more text. Hold that one, write - # out the rest. - # - # Some of those breaks are not line endings to us, a form - # feed for one, so a piece can go out without ending in a - # newline. That beats what we did before, which was to stop - # at the first such piece and drop every complete line - # behind it. - if lines and not lines[-1].endswith(("\n", "\r")): - self._line_buffer = lines.pop() - else: - self._line_buffer = "" - for line in lines: - self._emit(line) - - # We tell idf.py it is talking to a terminal, so it sends progress - # bars and cursor moves. Our own stdout is usually a pipe, which is - # block buffered, so without this the build looks frozen until - # 8 KiB of output piles up. - self._stream.flush() - return len(data) - - if len(sys.argv) < 2: - print( - "usage: runner.py [args...]", - file=sys.stderr, - ) - return 2 - - script_path = sys.argv[1] - - # Mirror the platformio runner behaviour: verbose mode disables the - # line filter so all output reaches the user. - is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) - filter_lines = None if is_verbose else FILTER_IDF_LINES or None - - stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] - - # Shift argv so the target script sees its own path as argv[0] and - # its own arguments starting at argv[1]. runpy.run_path does not - # modify sys.argv itself. - sys.argv = [script_path] + sys.argv[2:] - - # Emulate Python's default behaviour of prepending the script's - # directory to sys.path[0] when running ``python script.py``. - # runpy.run_path does not do this automatically, but idf.py relies - # on it to import its sibling modules (python_version_checker, - # idf_py_actions, ...). - script_dir = str(Path(script_path).resolve().parent) - if script_dir not in sys.path: - sys.path.insert(0, script_dir) - - # If idf.py calls sys.exit(), SystemExit propagates out of run_path - # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. Either way the streams get a chance to - # release a last line that never got its terminator. Drain the shims we - # made rather than sys.stdout, which the script is free to replace, and - # report instead of raising so cleanup cannot bury the real exit code. - try: - runpy.run_path(script_path, run_name="__main__") - finally: - # Drain stderr from a finally so a surprise from the first one cannot - # strand the second. - try: - stdout_shim.drain() - finally: - stderr_shim.drain() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 2be3634c693..ffe97ba6186 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -1,6 +1,6 @@ """PlatformIO-format RAM/Flash one-liners after a native ESP-IDF build. -``idf.py size`` (chained onto ``idf.py build`` in +The ninja ``size`` target (run after ``all`` in ``toolchain.run_compile``) prints the per-region table inline as part of the build. This module adds two summary lines underneath, byte-identical to PlatformIO's output: @@ -9,16 +9,19 @@ byte-identical to PlatformIO's output: Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes) The format matches ``script/ci_memory_impact_extract.py`` so CI memory -analysis works unchanged on native ESP-IDF builds. RAM total is the -DRAM region size from the linker map; Flash total is taken from +analysis works unchanged on native ESP-IDF builds. RAM usage comes from +the DRAM (or unified DIRAM) region of the linker map. Flash used is the +exact image size matching the ``Total image size`` line: json2 +``total_size`` when present, otherwise derived from the ELF (see +``_image_size_from_elf``). Flash total is taken from ``partitions.csv`` using PlatformIO's rule (first app partition whose subtype is ``factory`` or ``ota_0``; see ``platform-espressif32/builder/main.py::_update_max_upload_size``). Structured size data is produced at link time by a CMake POST_BUILD custom command (see ``build_gen/espidf.py``) which writes -``esp_idf_size.json`` next to the ELF. We read that file here rather -than re-running ``esp_idf_size`` from Python. +``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary) +next to the ELF; we read that rather than re-running ``esp_idf_size``. """ from __future__ import annotations @@ -27,6 +30,7 @@ import csv import json import logging from pathlib import Path +import struct from esphome.build_helpers.size_summary import print_size_line @@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int: raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") -def print_summary(size_json: Path, partitions_csv: Path | None) -> None: +def _image_size_from_elf(elf: Path) -> int: + """Sum the allocated PROGBITS section sizes from an ELF32 file. + + Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte; + esptool's ``ELFFile`` filters sections differently and would not. + Raises ``ValueError`` for anything but a well-formed ELF32 LE file. + """ + with elf.open("rb") as f: + header = f.read(52) # ELF32 header + if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01": + raise ValueError(f"{elf} is not a 32-bit little-endian ELF") + (e_shoff,) = struct.unpack_from(" None: """Print PlatformIO-shaped RAM and Flash one-liners. Failures are non-fatal: the build has already succeeded, we just couldn't - summarize. Logs the cause at debug level. + summarize. Anomalies (missing region, unreadable ELF) warn; expected + optional inputs (no size json, no partitions.csv) log at debug. """ if not size_json.is_file(): _LOGGER.debug("Skipping size summary: %s not found", size_json) @@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Skipping size summary: %s", e) return - - memory_types = data.get("memory_types", {}) - ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} - ram_used = ram_region.get("used") - ram_total = ram_region.get("size") - if ram_total and ram_used is not None: - print_size_line("RAM", ram_used, ram_total) - - image_size = data.get("image_size") - if image_size is None or partitions_csv is None: + if not isinstance(data, dict): + _LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json) return + + layout = data.get("layout") + regions = { + entry.get("name"): entry + for entry in (layout if isinstance(layout, list) else []) + if isinstance(entry, dict) + } + # Every chip has a DRAM or DIRAM region, so a warning here usually + # means the esp_idf_size json schema changed + ram_region = regions.get("DRAM") or regions.get("DIRAM") + if ram_region is None: + _LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json) + elif ( + isinstance(ram_total := ram_region.get("total"), int) + and ram_total > 0 + and isinstance(ram_used := ram_region.get("used"), int) + ): + print_size_line("RAM", ram_used, ram_total) + else: + _LOGGER.warning( + "Skipping RAM summary: unusable region %s in %s", ram_region, size_json + ) + + # esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in + # json2; older 1.x omits it, so derive the same figure from the ELF. + flash_used = data.get("total_size") + if not (isinstance(flash_used, int) and flash_used > 0): + _LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf) + try: + flash_used = _image_size_from_elf(firmware_elf) + except (OSError, ValueError) as e: + # The ELF must be present and well formed after a successful build + _LOGGER.warning("Skipping Flash summary: %s", e) + return try: app_size = _find_app_partition_size(partitions_csv) - except ValueError as e: + except (OSError, ValueError) as e: _LOGGER.debug("Skipping Flash summary: %s", e) return - print_size_line("Flash", image_size, app_size) + if app_size <= 0: + _LOGGER.debug("Skipping Flash summary: app partition size is 0") + return + print_size_line("Flash", flash_used, app_size) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 986f9dfb8bd..4243eff0ba2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import re import shutil import subprocess +from esphome.build_helpers.tool_runner import run_build_tool from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, @@ -137,7 +138,9 @@ def _get_cmake_output(build_dir) -> str: if not (Path(build_dir) / "CMakeCache.txt").is_file(): raise EsphomeError(f"No ESP-IDF build found in {build_dir}") - cmd = ["cmake", "-LA", "-N", "."] + # Resolve to an absolute path: Windows locates a child process + # through the parent's PATH, not the env passed to it. + cmd = [_get_idf_tool("cmake"), "-LA", "-N", "."] env = _get_idf_env() result = subprocess.run( @@ -189,72 +192,235 @@ def _get_idf_tool(name: str) -> str: return executable -def run_idf_py( - *args, - cwd: Path | None = None, - capture_output: bool = False, - jobs: int | None = None, -) -> int | str: - """Run idf.py with the given arguments.""" - idf_path = _get_idf_path() - if idf_path is None: - raise EsphomeError("ESP-IDF not found") +# Lines dropped from cmake and ninja output unless ``-v`` is given; matched +# with ``re.match`` against the line without ANSI codes or trailing space. +FILTER_IDF_LINES: list[str] = [ + # Full component path and linker script lists, one giant line each. + r"-- Component paths:", + r"-- Adding linker script ", + r"-- Components:", + # Component manager notices; progress dots can precede them. + r"\.*NOTICE: ", + # esp_idf_size banner and trailing note around the size table. + r"\s*Memory Type Usage Summary", + r"Note: The reported total sizes may be smaller than those in the", + r"\s*$", + # esphome-libs tarballs have no .git, so IDF's commit probes fail noisily. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", +] - env = _get_idf_env() - if jobs is not None: - env = {**env, "IDF_PY_BUILD_JOBS": str(jobs)} - python_executable = _get_idf_tool("python") - idf_py = idf_path / "tools" / "idf.py" - # Dispatch idf.py through esphome.espidf.runner, which wraps - # sys.stdout/sys.stderr so ``isatty()`` reports True. This keeps CMake, - # Ninja, and idf.py's own progress-bar code emitting TTY-format output - # (``\r`` cursor moves, ANSI colors, fancy progress bars) even when our - # real stdout is a pipe — e.g. when esphome is running under the Home - # Assistant dashboard add-on. The runner is a plain script (not a - # ``python -m`` module) because IDF's Python venv does not have the - # esphome package installed. - runner_py = Path(__file__).parent / "runner.py" - - cmd = [python_executable, str(runner_py), str(idf_py)] + list(args) - - if cwd is None: - cwd = CORE.build_path - - _LOGGER.debug("Running: %s", " ".join(cmd)) - _LOGGER.debug(" in directory: %s", cwd) - - if capture_output: - result = subprocess.run( - cmd, - cwd=cwd, - env=env, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - _LOGGER.error("idf.py failed:\n%s", result.stderr) - return result.stdout - result = subprocess.run( - cmd, - cwd=cwd, - env=env, - check=False, - ) - return result.returncode +# click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE. +_CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) +_CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$") -def _get_sdkconfig_args() -> list[str]: - """Get cmake -D flags for the sdkconfig file, if it exists.""" +@dataclass(frozen=True, kw_only=True) +class _IdfPyContract: + """How the pinned idf.py drives cmake and ninja (tools/idf_py_actions).""" + + binary_dir_arg: bool # cmake gets -B + ccache_as_bool: bool # CCACHE_ENABLE=True/False instead of 1/0 + size_ng: bool # size target gets ESP_IDF_SIZE_NG=1 + + +_IDF_PY_5 = _IdfPyContract( + binary_dir_arg=False, + ccache_as_bool=False, + size_ng=True, +) +_IDF_PY_6 = _IdfPyContract( + binary_dir_arg=True, + ccache_as_bool=True, + size_ng=False, +) + + +def _idf_py() -> _IdfPyContract: + from esphome.components.esp32 import idf_version + import esphome.config_validation as cv + + return _IDF_PY_6 if idf_version() >= cv.Version(6, 0, 0) else _IDF_PY_5 + + +def _build_dir() -> Path: + """The CMake binary dir; idf.py resolves the project dir the same way.""" + return Path(os.path.realpath(CORE.build_path)) / "build" + + +def _cache_entries() -> dict[str, str]: + """The ``-D`` entries idf.py passes to cmake, in idf.py's order.""" + entries = {} sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") if sdkconfig_path.is_file(): - return ["-D", f"SDKCONFIG={sdkconfig_path}"] - return [] + entries["SDKCONFIG"] = str(sdkconfig_path) + ccache = _get_idf_env().get("IDF_CCACHE_ENABLE", "").strip().lower() in _CLICK_TRUE + entries["CCACHE_ENABLE"] = str(ccache if _idf_py().ccache_as_bool else int(ccache)) + return entries -def run_reconfigure() -> int: - """Run cmake reconfigure only (no build).""" - return run_idf_py(*_get_sdkconfig_args(), "reconfigure") +def _parse_cmakecache(path: Path) -> dict[str, str]: + """Map each ``NAME:TYPE=VALUE`` line of a CMakeCache.txt to NAME: VALUE.""" + result = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if m := _CMAKECACHE_LINE.match(line): + result[m.group(1)] = m.group(3) + return result + + +def _cache_entries_changed() -> bool: + """True when a ``-D`` entry is missing from or differs in CMakeCache.txt. + + idf.py reconfigures on this before every build; ESPHome's own staleness + check does not cover it (for example ccache switched on or off). ESPHome + also compares ``PYTHON``, so a moved IDF prefix reconfigures; idf.py + stopped with an error instead. + """ + cache_path = _build_dir() / "CMakeCache.txt" + if not cache_path.is_file(): + return True + cache = _parse_cmakecache(cache_path) + return any(cache.get(k) != v for k, v in _configure_defines().items()) + + +def _configure_defines() -> dict[str, str]: + """Every ``-D`` idf.py passes to cmake, in its order.""" + return { + "PYTHON_DEPS_CHECKED": "1", + "PYTHON": _get_idf_tool("python"), + "ESP_PLATFORM": "1", + **_cache_entries(), + } + + +def _tool_env() -> dict[str, str]: + """The IDF env plus color, as idf.py 6.x gives every tool. + + Also used on 5.x (which forced CLICOLOR_FORCE for ninja only); color + changes only what is printed, and this way NO_COLOR is respected. + """ + env = dict(_get_idf_env()) + if not env.get("NO_COLOR"): + env.setdefault("CLICOLOR_FORCE", "1") + env.setdefault("FORCE_COLOR", "1") + return env + + +def run_reconfigure(verbose: bool = False) -> int: + """Run the CMake configure, with the arguments idf.py uses.""" + build_dir = _build_dir() + build_dir.mkdir(parents=True, exist_ok=True) + cmd = [_get_idf_tool("cmake"), "-G", "Ninja"] + if _idf_py().binary_dir_arg: + cmd += ["-B", str(build_dir)] + cmd += [f"-D{name}={value}" for name, value in _configure_defines().items()] + cmd.append(str(build_dir.parent)) + log_path = build_dir / "log" / "cmake_output.log" + rc = run_build_tool( + cmd, + cwd=build_dir, + env=_tool_env(), + filter_lines=None if verbose else FILTER_IDF_LINES, + log_path=log_path, + ) + if rc != 0: + # As idf.py does: a partial cache must not look configured. + (build_dir / "CMakeCache.txt").unlink(missing_ok=True) + _LOGGER.error("CMake configure failed with exit code %d", rc) + _print_hints(log_path) + return rc + + +def _size_env() -> dict[str, str]: + """Environment idf.py gives the ``size`` target.""" + env = {"ESP_IDF_SIZE_FORCE_TERMINAL": "1", "SIZE_OUTPUT_FORMAT": "default"} + if _idf_py().size_ng: + env["ESP_IDF_SIZE_NG"] = "1" + return env + + +def _build_jobs(config) -> int | None: + """Ninja's -j: compile_process_limit, else IDF_PY_BUILD_JOBS as idf.py read it.""" + if (limit := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT)) is not None: + return limit + if not (value := os.environ.get("IDF_PY_BUILD_JOBS")): + return None + try: + jobs = int(value) + except ValueError: + jobs = 0 + if jobs <= 0: + raise EsphomeError("IDF_PY_BUILD_JOBS must be a positive integer") + return jobs + + +def _run_ninja( + target: str, + *, + verbose: bool, + jobs: int | None, + progress: bool = False, + extra_env: dict[str, str] | None = None, +) -> int: + """Build one ninja target, with the flags and env idf.py uses.""" + cmd = [_get_idf_tool("ninja")] + if jobs is not None: + cmd += ["-j", str(jobs)] + if verbose: + cmd.append("-v") + cmd.append(target) + log_path = _build_dir() / "log" / f"ninja_{Path(target).name}_output.log" + rc = run_build_tool( + cmd, + cwd=_build_dir(), + env={**_tool_env(), **(extra_env or {})}, + filter_lines=None if verbose else FILTER_IDF_LINES, + progress=progress and not verbose, + log_path=log_path, + ) + if rc != 0: + _LOGGER.error("ninja %s failed with exit code %d", target, rc) + _print_hints(log_path) + return rc + + +# Runs IDF's own hint matcher (hints.yml plus its hint modules) on a failed +# tool's output, as idf.py did; it only lives in the IDF venv. +_HINTS_SCRIPT = """ +import sys +sys.path.insert(0, sys.argv[1]) +from idf_py_actions.tools import generate_hints +for hint in generate_hints(sys.argv[2]): + print(hint) +""" + + +def _print_hints(log_path: Path) -> None: + """Print ESP-IDF's advice for a failed build; never fails the build itself.""" + try: + result = subprocess.run( + [ + _get_idf_tool("python"), + "-c", + _HINTS_SCRIPT, + str(_get_idf_path() / "tools"), + str(log_path), + ], + env=_get_idf_env(), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (OSError, subprocess.SubprocessError, EsphomeError) as err: + _LOGGER.debug("Could not get ESP-IDF hints: %s", err) + return + if result.returncode != 0: + _LOGGER.debug("Could not get ESP-IDF hints:\n%s", result.stderr) + return + if hints := result.stdout.strip(): + _LOGGER.warning("%s", hints) def _builtin_component_cache_path() -> Path | None: @@ -313,7 +479,9 @@ def save_cached_builtin_components(components: list[str]) -> None: _LOGGER.warning("Could not write component list cache %s: %s", path, err) -def _write_project_and_reconfigure(builtin_components: list[str] | None) -> int: +def _write_project_and_reconfigure( + builtin_components: list[str] | None, verbose: bool = False +) -> int: """Write the full CMakeLists.txt and run the configure for it.""" from esphome.build_gen.espidf import write_project @@ -321,12 +489,12 @@ def _write_project_and_reconfigure(builtin_components: list[str] | None) -> int: write_project(minimal=False, builtin_components=builtin_components) # Explicit reconfigure: ninja only re-runs cmake when CMakeLists.txt # is strictly newer than build.ninja, which fails on coarse-mtime - # filesystems (#18682). Also keeps idf.py from regenerating memory.ld + # filesystems (#18682). Also keeps ninja from regenerating memory.ld # in testing mode. - return run_reconfigure() + return run_reconfigure(verbose) -def _configure_project() -> int: +def _configure_project(verbose: bool = False) -> int: """Configure the project, discovering the built-in components if needed. A cached component list skips the discovery configure. If the configure @@ -337,20 +505,20 @@ def _configure_project() -> int: if (cached := load_cached_builtin_components()) is not None: _LOGGER.info("Using cached ESP-IDF component list") - if _write_project_and_reconfigure(cached) == 0: + if _write_project_and_reconfigure(cached, verbose) == 0: return 0 _LOGGER.warning("Cached component list failed; rediscovering") _builtin_component_cache_path().unlink(missing_ok=True) _LOGGER.info("Discovering available ESP-IDF components...") write_project(minimal=True) - if (rc := run_reconfigure()) != 0: + if (rc := run_reconfigure(verbose)) != 0: _LOGGER.error("Component discovery failed") return rc discovered = get_available_components() if not discovered: _LOGGER.error("Component discovery found no built-in ESP-IDF components") return 1 - if (rc := _write_project_and_reconfigure(discovered)) != 0: + if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0: _LOGGER.error("Reconfigure with discovered components failed") return rc save_cached_builtin_components(discovered) @@ -489,11 +657,9 @@ def run_compile(config, verbose: bool) -> int: 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ - # Check if we need to do discovery phase - if not need_reconfigure(): - _LOGGER.info("Build configuration is up to date") - else: - if (rc := _configure_project()) != 0: + jobs = _build_jobs(config) + if need_reconfigure(): + if (rc := _configure_project(verbose)) != 0: return rc # cmake does not rewrite CMakeCache.txt when only properties change, # so restamp it or every build repeats discovery. Only after success, @@ -504,52 +670,42 @@ def run_compile(config, verbose: bool) -> int: path = CORE.relative_build_path(name) if path.is_file(): os.utime(path) + elif _cache_entries_changed(): + _LOGGER.info("CMake cache options changed, reconfiguring") + if (rc := run_reconfigure(verbose)) != 0: + return rc + else: + _LOGGER.info("Build configuration is up to date") # In testing mode, generate the linker script first, patch DRAM/IRAM sizes, # then build. memory.ld is regenerated by ninja during the build phase, # so we must patch after it's generated but before linking (same timing # as iram_fix.py.script's AddPreAction hook in the PlatformIO path). if CORE.testing_mode: - memory_ld = CORE.relative_build_path( - "build", "esp-idf", "esp_system", "ld", "memory.ld" - ) - build_dir = CORE.relative_build_path("build") - # Build just the memory.ld target - ninja needs the path relative to build dir - memory_ld_target = os.path.relpath(str(memory_ld), str(build_dir)) - env = _get_idf_env() - ninja_executable = _get_idf_tool("ninja") - result = subprocess.run( - [ninja_executable, "-C", str(build_dir), memory_ld_target], - env=env, - check=False, - ) - if result.returncode != 0: - _LOGGER.error("Failed to generate linker script") - return result.returncode + memory_ld = str(Path("esp-idf", "esp_system", "ld", "memory.ld")) + if (rc := _run_ninja(memory_ld, verbose=verbose, jobs=jobs)) != 0: + return rc _patch_memory_segments() - # Build - args = [] + from esphome.build_gen.espidf import write_pch_checksum - if verbose: - args.append("-v") + write_pch_checksum() - args.extend(_get_sdkconfig_args()) - args.append("build") - args.append("size") - - rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT)) + # idf.py's ``build size``, minus the second ``ninja all`` it runs first. + rc = _run_ninja("all", verbose=verbose, jobs=jobs, progress=True) + if rc == 0: + rc = _run_ninja("size", verbose=verbose, jobs=jobs, extra_env=_size_env()) if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") - print_summary(size_json, partitions if partitions.is_file() else None) + print_summary(size_json, partitions, get_built_elf_path()) return rc def get_firmware_path() -> Path: """Get the path to the compiled firmware binary. - This is the file idf.py writes directly (named after the project), + This is the file the build writes directly (named after the project), not the copy used for OTA/factory downloads below. """ build_dir = CORE.relative_build_path("build") @@ -579,10 +735,20 @@ def get_ota_firmware_path() -> Path: return build_dir / "firmware.ota.bin" +def get_built_elf_path() -> Path: + """Path to the ELF the build writes directly, ``/.elf``. + + Exists as soon as the build finishes, unlike the ``firmware.elf`` + copy that ``create_elf_copy`` makes later. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.elf" + + def get_elf_path() -> Path: """Get the path to the firmware ELF file. - idf.py writes ``/.elf`` directly; this returns the + The build writes ``/.elf`` directly; this returns the ``/firmware.elf`` copy created by ``create_elf_copy`` so the dashboard's "download ELF" link can find it under the PlatformIO-convention name. @@ -591,6 +757,13 @@ def get_elf_path() -> Path: return build_dir / "firmware.elf" +def get_cmake_cache_value(var_name: str) -> str | None: + """One entry of the configured build's CMake cache, or None when unset.""" + cmake_output = _get_cmake_output(CORE.relative_build_path("build")) + match = re.search(rf"^{var_name}:\w+=(.*)$", cmake_output, re.MULTILINE) + return match.group(1).strip() if match else None + + def get_objdump_path() -> Path: return _get_cmake_tool_path("CMAKE_OBJDUMP") @@ -702,12 +875,11 @@ def create_ota_bin() -> bool: def create_elf_copy() -> bool: """Copy the ELF binary to firmware.elf for dashboard compatibility. - idf.py writes the ELF at ``/.elf``; the dashboard's + The build writes the ELF at ``/.elf``; the dashboard's "download ELF" link requests the literal filename ``firmware.elf`` (PlatformIO convention), so copy it to that name. """ - build_dir = CORE.relative_build_path("build") - src_elf = build_dir / f"{CORE.name}.elf" + src_elf = get_built_elf_path() dst_elf = get_elf_path() if not src_elf.is_file(): diff --git a/esphome/espota2.py b/esphome/espota2.py index ce403c398db..952f88fc4b6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # across the addresses on top of that. EXTRA_UPLOAD_ATTEMPTS = 2 UPLOAD_RETRY_DELAY = 5.0 +# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA +# (105 s) so a stalled session is gone before a retry, and long enough for lwIP +# to get a lost chunk ack through after the retransmit run seen in practice +DATA_PHASE_TIMEOUT = 160.0 _LOGGER = logging.getLogger(__name__) @@ -207,6 +211,27 @@ class OTAEncryptionFallback(OTAError): """The encrypted attempt failed and the caller may retry in plaintext.""" +# Uploader side option under `ota: encryption:`; the ota component imports the +# name so the upload path never loads the component module +CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( + f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " + "migrates a device which never encrypted. If this device encrypted before, " + "something on the network stripped the offer: remove the option and check " + "the network." +) +# Logged only once the device is seen encrypting, so the migration install +# itself is never nagged and the user learns exactly when removal is safe +ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING = f""" +****************************************************************** +* This device offers OTA encryption and accepted the key, so +* '{CONF_ALLOW_PLAINTEXT_UPLOAD}' under 'ota: encryption:' has done +* its job. Remove it from the configuration now, together with +* any 'password:' on that block: leaving the option in place lets +* an attacker on the network strip the encryption offer and +* downgrade a future upload to plaintext. +******************************************************************""" + # Remove before 2027.3.0 PLAINTEXT_FALLBACK_NOTICE = ( "A device with an api encryption key offers encryption after this " @@ -508,6 +533,7 @@ def perform_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -573,25 +599,32 @@ def perform_ota( features = 0 if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE): - if plaintext_fallback: - # Remove before 2027.3.0: older firmware that cannot encrypt still - # gets its update on this connection + # Remove before 2027.3.0: drop `or plaintext_fallback` and + # PLAINTEXT_FALLBACK_NOTICE here; allow_plaintext_upload stays + if allow_plaintext_upload or plaintext_fallback: + # The running firmware cannot encrypt; it still gets this update, + # and the build being sent offers encryption for the next one _LOGGER.warning( "The device did not offer OTA encryption; continuing in plaintext. %s", - PLAINTEXT_FALLBACK_NOTICE, + ALLOW_PLAINTEXT_UPLOAD_NOTICE + if allow_plaintext_upload + else PLAINTEXT_FALLBACK_NOTICE, ) noise_psk = None else: # Fail closed: an attacker could otherwise strip the offer and # capture the image (wifi credentials, api key) + # Remove before 2027.3.0: installing without the block no longer + # falls back then; advise 'allow_plaintext_upload: true' instead raise OTAError( "An OTA encryption key is configured but the device did not " "offer encryption; refusing to send the image in plaintext. " "The running firmware predates ESPHome 2026.9.0 or has no " "'api: encryption: key'. With an api key, install once " "without the 'ota: encryption:' block (that build offers " - "encryption), then restore it; otherwise flash by serial or " - "the web_server OTA platform." + f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " + "true' under 'ota: encryption:' for this one install, or flash by " + "serial or the web_server OTA platform." ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -615,6 +648,8 @@ def perform_ota( raise OTAEncryptionFallback(str(err)) from err raise _LOGGER.info("Encrypted connection established") + if allow_plaintext_upload: + _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -694,8 +729,7 @@ def perform_ota( _LOGGER.info("Handshake complete") - # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures - sock.settimeout(90.0) + sock.settimeout(DATA_PHASE_TIMEOUT) if extended_proto: send_check(sock, ota_type, "ota type") @@ -821,6 +855,7 @@ def run_ota_impl_( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -854,7 +889,7 @@ def run_ota_impl_( # clean up a half-open connection (its handshake watchdog runs at 20s); # moving on to the next address family stays immediate. Known limitation: # a silent mid-transfer drop with no reset can wedge the device until its - # 90s data timeout, which outlasts this budget; the retries target the + # 105s data timeout, which outlasts this budget; the retries target the # common failures where the device resets or closes the link promptly. total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" @@ -896,6 +931,7 @@ def run_ota_impl_( ota_type, encryption.noise_psk, encryption.plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) except OTAEncryptionFallback as err: # Same address and attempt budget: not a network retry @@ -937,6 +973,7 @@ def run_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -947,6 +984,7 @@ def run_ota( ota_type, noise_psk, plaintext_fallback, + allow_plaintext_upload, ) except OTAError as err: _LOGGER.error(err) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 82bc0d37272..86010e30650 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -15,7 +15,7 @@ import threading import time from typing import IO, TYPE_CHECKING -from esphome.helpers import ProgressBar, rmtree +from esphome.helpers import ProgressBar, get_usable_cpu_count, rmtree from esphome.net_retry import ( NETWORK_MAX_ATTEMPTS, http_request, @@ -23,6 +23,7 @@ from esphome.net_retry import ( ) if TYPE_CHECKING: + from filelock import FileLock import requests PathType = str | os.PathLike @@ -287,10 +288,25 @@ def _detect_archive_root(names: Iterable[str]) -> str | None: return root if has_descendant else None +def _resolve_progress( + progress: Callable[[float], None] | None, + progress_header: str | None, + has_work: bool, +) -> Callable[[float], None] | None: + """Fraction reporter for an extractor: the caller's callback wins over a + private ``progress_header`` bar.""" + if progress is not None: + return progress + if progress_header and has_work: + return ProgressBar(progress_header).update + return None + + def _tar_extract_all( data: io.BufferedIOBase, extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract a TAR archive to the specified directory. @@ -305,6 +321,7 @@ def _tar_extract_all( data: File-like object containing the TAR archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: fraction callback (0..1, ends at 1.0); overrides progress_header """ import tarfile @@ -363,21 +380,23 @@ def _tar_extract_all( safe_members.append(member) total = len(safe_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) + report = _resolve_progress(progress, progress_header, total > 0) for i, member in enumerate(safe_members, 1): - tar_ref.extract(member, abs_dest) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) + # Named: the default is fully_trusted on 3.12/3.13, data on + # 3.14. The pre-pass drops unsafe members; an escape past it + # gains nothing, since the build runs what these archives hold. + tar_ref.extract(member, abs_dest, filter="fully_trusted") + if report is not None: + report(i / total) + if report is not None: + report(1) def _zip_extract_all( data: io.BufferedIOBase, extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract a ZIP archive to the specified directory. @@ -386,6 +405,7 @@ def _zip_extract_all( data: File-like object containing the ZIP archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: fraction callback (0..1, ends at 1.0); overrides progress_header """ import zipfile @@ -402,9 +422,7 @@ def _zip_extract_all( strip_prefix = f"{strip_root}/" if strip_root is not None else None total = len(all_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) + report = _resolve_progress(progress, progress_header, total > 0) for i, member in enumerate(all_members, 1): # 1. Normalize name @@ -437,10 +455,10 @@ def _zip_extract_all( # 6. Extract zip_ref.extract(member, extract_dir) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) + if report is not None: + report(i / total) + if report is not None: + report(1) def _rename_with_retry( @@ -471,6 +489,7 @@ def _7z_extract_all( data: io.BufferedIOBase, extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract a 7z archive to the specified directory. @@ -485,6 +504,7 @@ def _7z_extract_all( data: File-like object containing the 7z archive (must be seekable) extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: called with 1.0 on completion; overrides progress_header """ import py7zr @@ -523,19 +543,15 @@ def _7z_extract_all( continue safe_targets.append(raw) - progress = ( - ProgressBar(progress_header) - if progress_header and safe_targets - else None - ) + report = _resolve_progress(progress, progress_header, bool(safe_targets)) if len(safe_targets) == len(all_names): z.extractall(path=staging) else: z.extract(path=staging, targets=safe_targets) - if progress is not None: - progress.update(1) + if report is not None: + report(1) src_root = staging / strip_root if strip_root else staging for item in src_root.iterdir(): @@ -566,6 +582,7 @@ def archive_extract_all( archive: PathType | io.RawIOBase | IO[bytes], extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract an archive file to the specified directory. @@ -574,6 +591,7 @@ def archive_extract_all( archive: Path to archive file or file-like object extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: fraction callback (0..1, ends at 1.0); overrides progress_header Raises: TypeError: If archive is not a valid type @@ -604,7 +622,9 @@ def archive_extract_all( break if matched_fct is None: raise ValueError("Unsupported archive format") - matched_fct(archive_ref, extract_dir, progress_header=progress_header) + matched_fct( + archive_ref, extract_dir, progress_header=progress_header, progress=progress + ) def _open_ranged( @@ -768,13 +788,23 @@ def _stream_response_to_file( # hammering the host or the mirrors. BATCH_DOWNLOAD_WORKERS = 4 +# Measured: gz peaks near 2 workers (8 is slower than serial), xz +# plateaus by 4 and holds ~50 MB of dictionary per worker. +BATCH_EXTRACT_WORKERS = 4 + + +def extract_workers(jobs: int | None = None) -> int: + """Worker count for an extraction batch of ``jobs`` archives.""" + workers = min(get_usable_cpu_count(), BATCH_EXTRACT_WORKERS) + return workers if jobs is None else min(workers, jobs) + def run_batch_downloads( header: str, jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]], max_workers: int = BATCH_DOWNLOAD_WORKERS, ) -> list[tuple[str, BaseException]]: - """Run ``(name, size, fetch)`` download jobs concurrently under one bar. + """Run ``(name, size, fetch)`` jobs concurrently under one bar. Each ``fetch(tracker)`` reports absolute byte counts; the bar total is the sum of the sizes. Failures are returned after the bar is done so @@ -909,6 +939,61 @@ def _part_path(dest: Path) -> Path: 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: """Remove ``dest`` and the resume sidecars of an abandoned download.""" part = _part_path(dest) @@ -949,15 +1034,26 @@ def resume_fetch_job( return fetch -def warn_prefetch_failures( +def is_expected_fetch_error(err: BaseException) -> bool: + """Download failures the callers degrade on, vs programming errors.""" + from esphome.core import EsphomeError # local import avoids circular dependency + + return isinstance(err, (EsphomeError, OSError)) + + +def warn_batch_failures( failures: list[tuple[str, BaseException]], - message: str = "Could not prefetch %s: %s", + message: str, ) -> None: - """Warn per failed batch-prefetch job; the caller's installer retries them.""" + """Warn per failed batch job, keeping the traceback of unexpected errors.""" for name, err in failures: # failure_reason: a message-less exception must not log blank - _LOGGER.warning(message, name, failure_reason(err)) - _LOGGER.debug("Prefetch failure detail", exc_info=err) + if is_expected_fetch_error(err): + _LOGGER.warning(message, name, failure_reason(err)) + _LOGGER.debug("Failure detail", exc_info=err) + else: + # A programming error must not be reduced to a bare message + _LOGGER.warning(message, name, failure_reason(err), exc_info=err) def download_with_resume( @@ -1319,10 +1415,7 @@ def download_from_mirrors( ) # Tick with the bytes already on disk so a combined bar holds # steady during the backoff instead of rewinding to zero - done = 0 - if progress is not None: - part = _part_path(path_target) - done = part.stat().st_size if part.is_file() else 0 + done = downloaded_bytes(path_target) if progress is not None else 0 _cancellable_sleep(delay, progress, done) # 3. Report every attempted URL if all mirrors failed. failures spans diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py index 35092e7daab..8b0d0208621 100644 --- a/esphome/happy_eyeballs.py +++ b/esphome/happy_eyeballs.py @@ -69,10 +69,7 @@ def _make_create_connection() -> Callable[..., socket.socket]: from aiohappyeyeballs import start_connection from urllib3.exceptions import LocationParseError - from urllib3.util.connection import ( # noqa: PLC2701 - _set_socket_options, - allowed_gai_family, - ) + from urllib3.util.connection import _set_socket_options, allowed_gai_family # noqa: PLC2701 from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 from esphome import async_thread diff --git a/esphome/host/__init__.py b/esphome/host/__init__.py new file mode 100644 index 00000000000..003b47eb99a --- /dev/null +++ b/esphome/host/__init__.py @@ -0,0 +1,10 @@ +"""Native (PlatformIO-free) build support for the host platform. + +Builds the generated sources with the machine's own C/C++ compiler through a +ninja file and drives the build directly -- the host equivalent of +``esphome.espidf``. Nothing is downloaded: the compiler comes from PATH and +registry libraries go through the shared library converter. + +Deliberately importable without the host component to avoid circular +imports; the component wires these modules in via lazy imports. +""" diff --git a/esphome/host/toolchain.py b/esphome/host/toolchain.py new file mode 100644 index 00000000000..47b5bda48c7 --- /dev/null +++ b/esphome/host/toolchain.py @@ -0,0 +1,196 @@ +"""Native host build driver (the PlatformIO ``run`` equivalent). + +The compiler and binutils come from PATH (``CC``/``CXX``/``AR``/``OBJDUMP``/ +``READELF`` override the lookup, like make and CMake), ninja from PATH or +the ninja PyPI wheel, and ccache is used when found. The build lives under +``.pioenvs//`` so ``CORE.firmware_bin`` and the clean paths stay the +ones the PlatformIO build used. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +from typing import NamedTuple + +from esphome.build_helpers.ccache import ccache_env, resolve_absolute_ccache_path +from esphome.build_helpers.native import warn_ignored_platformio_options +from esphome.build_helpers.ninja import ( + absolute_tool, + find_ninja, + refresh_compile_commands, +) +from esphome.build_helpers.tools_cache import HOST_TOOLS_CACHE +from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME +from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# The output name PlatformIO's native platform produced; CORE.firmware_bin +# and the integration-test harness resolve it by this name +PROGRAM_NAME = "program" + +# platformio_options keys the host build reads (lib_ignore feeds the library +# converter); anything else has no native equivalent and is warned about +CONSUMED_PIO_OPTIONS = frozenset({"lib_ignore"}) + + +class HostCompilers(NamedTuple): + """The resolved C and C++ compiler commands: the program, then any + arguments its override carried (``CC="gcc -m32"``).""" + + cc: tuple[str, ...] + cxx: tuple[str, ...] + + +def find_command(env_var: str, candidates: tuple[str, ...]) -> tuple[str, ...]: + """Resolve a build tool: ``env_var`` when set, else the first candidate + found on PATH. Returns the program and the override's arguments. + + An override that does not resolve fails by name rather than falling back + silently to a different compiler than the user asked for. + """ + if override := os.environ.get(env_var, "").strip(): + program, *args = shlex.split(override) + if Path(program).stem == "ccache" and args: + # The build adds ccache itself; twice would hide the compiler + program, *args = args + # which() accepts an absolute path as well as a bare program name + if (resolved := shutil.which(program)) is None: + raise EsphomeError( + f"{env_var}={override!r} does not name a runnable program" + ) + return (absolute_tool(resolved), *args) + for name in candidates: + if (found := shutil.which(name)) is not None: + return (absolute_tool(found),) + raise EsphomeError( + f"{candidates[0]} not found on PATH (tried {', '.join(candidates)}); " + f"install it or set {env_var}" + ) + + +def find_tool(env_var: str, candidates: tuple[str, ...]) -> str: + """Resolve a build tool that is called as a bare program.""" + program, *args = find_command(env_var, candidates) + if args: + raise EsphomeError(f"{env_var} must name a program without arguments") + return program + + +def find_compilers() -> HostCompilers: + """The C and C++ compilers the build uses (gcc first, as PlatformIO did).""" + return HostCompilers( + cc=find_command("CC", ("gcc", "clang", "cc")), + cxx=find_command("CXX", ("g++", "clang++", "c++")), + ) + + +def get_build_dir() -> Path: + return CORE.relative_pioenvs_path(CORE.name) + + +def get_elf_path() -> Path: + return get_build_dir() / PROGRAM_NAME + + +def get_objdump_path() -> Path: + return Path(find_tool("OBJDUMP", ("objdump",))) + + +def get_readelf_path() -> Path: + return Path(find_tool("READELF", ("readelf",))) + + +def check_analysis_supported() -> None: + """Refuse analyze-memory where the program is not an ELF file. + + Called before the compile, so an unsupported machine fails at once. + """ + if sys.platform != "linux": + raise EsphomeError( + "analyze-memory reads ELF files; the host build on " + f"{sys.platform} produces a different format" + ) + + +def get_build_env(ccache: str | None) -> dict[str, str]: + return {**os.environ, **ccache_env(ccache, HOST_TOOLS_CACHE)} + + +def run_compile(config: ConfigType, verbose: bool) -> int: + from esphome.build_gen import host as build_gen + + warn_ignored_platformio_options(CONSUMED_PIO_OPTIONS) + # Probe the cheap local dependencies before resolving libraries + ninja_path = find_ninja() + compilers = find_compilers() + # Resolved once per build: the resolution probes PATH and spawns the + # runnability check, and three consumers need the same answer + ccache = resolve_absolute_ccache_path() + ninja_changed = build_gen.write_project(compilers, ccache) + + build_dir = get_build_dir() + env = get_build_env(ccache) + refresh_compile_commands(ninja_path, build_dir, env, ninja_changed) + + cmd = [str(ninja_path)] + if verbose: + cmd.append("-v") + if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT): + cmd += ["-j", str(jobs)] + # The explicit target, not the default statement: a generator defect + # that drops it fails loudly with "unknown target" instead of a green + # no-op run that leaves a stale program in place + cmd.append(PROGRAM_NAME) + + _LOGGER.debug("Running: %s", " ".join(cmd)) + # cwd instead of -C also drops the "Entering directory" banner + rc = subprocess.run( + cmd, cwd=build_dir, env=env, check=False, close_fds=False + ).returncode + if rc != 0: + return rc + + elf = get_elf_path() + if not elf.is_file(): + # ninja refused a manifest missing the target above; this covers a + # rule that ran but wrote elsewhere + _LOGGER.error("Build produced no %s", elf) + return 1 + + from esphome.build_helpers.idedata import warn_if_idedata_missing + + warn_if_idedata_missing(lambda: _load_idedata(ccache)) + return 0 + + +def get_idedata() -> 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. Returns None + when nothing has been built yet. + """ + # Deliberately uncached: env/PATH can change between builds in a + # long-lived host process + return _load_idedata(resolve_absolute_ccache_path()) + + +def _load_idedata(ccache: str | None) -> dict | None: + from esphome.build_helpers.idedata import load_or_build_idedata + + return load_or_build_idedata( + get_build_dir() / "compile_commands.json", + get_elf_path(), + CORE.relative_internal_path("idedata", f"{CORE.name}.json"), + # The compile DB's commands carry the same ccache prefix the ninja + # rules were generated with + launcher=ccache, + ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 804a0af0627..f2a341d8bf3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 3.2.1 + version: 4.0.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: @@ -26,7 +26,7 @@ dependencies: espressif/mdns: version: 1.12.0 espressif/esp_wifi_remote: - version: 1.6.3 + version: 1.6.5 rules: - if: "target in [esp32h2, esp32p4]" espressif/wifi_remote_over_eppp: @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.12 + version: 2.12.13 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.2 + version: 0.8.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: @@ -110,12 +110,12 @@ dependencies: # refuses to build two managed components whose names differ only by # namespace. The Arduino envs get noise-c as a PlatformIO library instead. esphome/noise-c: - version: 0.1.21 + version: 0.1.30 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" # Declared even though noise-c depends on it, so that the PlatformIO-library # converter knows to skip the copy esp_wireguard would otherwise pull in. esphome/libsodium: - version: 1.10021.4 + version: 1.10021.11 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" diff --git a/esphome/pins.py b/esphome/pins.py index d6393508abf..34a406c2733 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -272,7 +272,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): num = conf[CONF_NUMBER] if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING): logger.warning( - "GPIO%s is a strapping PIN and should only be used for I/O with care.\n" + "GPIO%s is a strapping pin and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", num, diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index b49c46cfec2..f51b315b7bc 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -35,7 +35,7 @@ from esphome.framework_helpers import ( failure_reason, rmdir, run_batch_downloads, - warn_prefetch_failures, + warn_batch_failures, ) _LOGGER = logging.getLogger(__name__) @@ -360,7 +360,7 @@ class LibraryBackend: """ platform: str | None - framework: str + framework: str | None emit: Callable[["ConvertedLibrary"], None] cache_key: str # Owner-less names this returns True for are skipped by the walk; @@ -368,6 +368,10 @@ class LibraryBackend: # reconciles provided_requests after resolving provides: Callable[[str], bool] | None = None provided_requests: set[str] = field(default_factory=set) + # Accept a library without library.json/library.properties, as + # PlatformIO does (its defaults: src/ or the root, plus include/). Off + # for backends whose emitted build files need the manifest. + manifest_optional: bool = False def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -616,11 +620,20 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin + from platformio.project.helpers import get_project_cache_dir + from platformio.registry.client import RegistryClient + + # PlatformIO creates its HTTP cache dir without exist_ok, so two builds + # making their first registry lookup at once race on it + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: - self._registry_client = None self.pkg_type = "library" + self._registry_client = RegistryClient() + # The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages); + # instance-level so the ESPHome process never patches PlatformIO's class + self._registry_client.allowed_private_packages = lambda: False @staticmethod def is_system_compatible(value: Any, custom_system: Any = None) -> bool: @@ -855,6 +868,11 @@ def _url_or_none(value: Any) -> str | None: return value if parsed.scheme and parsed.netloc else None +# URL schemes that name a local library folder; symlink:// is PlatformIO's +# spelling for one it links instead of copying, which is the same to us +_LOCAL_SCHEMES = ("file", "symlink") + + def _node_key( name: str | None, version: str | None, repository: str | None ) -> tuple[str, str, tuple[str | None, str | None]]: @@ -892,7 +910,7 @@ def _node_key( scheme = urlsplit(candidate).scheme except ValueError: scheme = "" - if scheme == "file" or _url_or_none(candidate): + if scheme in _LOCAL_SCHEMES or _url_or_none(candidate): name, repository = custom_name, candidate else: # Anything with ``://`` was meant to be a URL; failing it fast @@ -901,7 +919,7 @@ def _node_key( if repository: is_git_prefixed = repository.startswith("git+") split_result = urlsplit(repository.removeprefix("git+")) - if split_result.scheme == "file" and not is_git_prefixed: + if (scheme := split_result.scheme) in _LOCAL_SCHEMES and not is_git_prefixed: # A plain file:// URL points at a local library directory. A local # file URL is written file:///absolute/path (empty host) or, less # commonly, file://localhost/path. Anything else -- a real host, or @@ -909,8 +927,8 @@ def _node_key( # rejected rather than silently resolved to the wrong directory. if split_result.netloc not in ("", "localhost"): raise RuntimeError( - f"Unsupported host in file:// library URL '{repository}'; " - "use an absolute path, e.g. file:///path/to/lib" + f"Unsupported host in {scheme}:// library URL '{repository}'; " + f"use an absolute path, e.g. {scheme}:///path/to/lib" ) # Validate the URL path itself (always POSIX-style, leading slash), # not the OS path: on Windows a "/foo" path is not is_absolute() @@ -920,8 +938,8 @@ def _node_key( url_path = split_result.path if not url_path.startswith("/") or not PurePosixPath(url_path).name: raise RuntimeError( - f"file:// library URL '{repository}' must be an absolute " - "directory path, e.g. file:///path/to/lib" + f"{scheme}:// library URL '{repository}' must be an absolute " + f"directory path, e.g. {scheme}:///path/to/lib" ) path = url2pathname(url_path) return (name or PurePosixPath(url_path).name), "local", (path, None) @@ -1091,7 +1109,7 @@ def _prefetch_wave( + [(c.name, 0, partial(_clone_source, c, salt, namespace)) for c in clones], ) # The sequential call below retries and raises the real error - warn_prefetch_failures( + warn_batch_failures( failures, "Prefetch of %s failed (retrying sequentially): %s" ) except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught @@ -1252,9 +1270,16 @@ def convert_libraries( library_properties_path = source_dir / "library.properties" has_json = library_json_path.is_file() has_properties = library_properties_path.is_file() - if not has_json and not has_properties and not node.is_local: + if ( + not has_json + and not has_properties + and not node.is_local + and not backend.manifest_optional + ): # An interrupted clone/extraction self-heals with one forced - # re-download; a local source has nothing to re-download + # re-download; a local source has nothing to re-download. + # A backend accepting manifest-less libraries cannot tell + # one from a torn download and would re-fetch every build. _LOGGER.warning( "Library %s at %s is missing library.json and library.properties; " "re-downloading", @@ -1268,6 +1293,12 @@ def convert_libraries( component.data = parse_library_json(library_json_path) elif has_properties: component.data = parse_library_properties(library_properties_path) + elif backend.manifest_optional: + _LOGGER.debug( + "Library %s has no manifest; using PlatformIO's default layout", + key, + ) + component.data = {"name": component.name} else: # Local sources are user input (EsphomeError); a registry/git # miss means a corrupt cache (RuntimeError) @@ -1294,7 +1325,7 @@ def convert_libraries( # cross-platform skip stays at debug, other causes warn if key in top_level_keys: reason = ( - f"is not compatible with {backend.framework}" + f"is not compatible with {backend.framework or backend.platform}" if isinstance(e, IncompatiblePlatform) else "has a malformed manifest" ) diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script new file mode 100644 index 00000000000..36751074d96 --- /dev/null +++ b/esphome/platformio/pch.py.script @@ -0,0 +1,300 @@ +import hashlib +import os +from pathlib import Path +import platform +import posixpath +import re +import shlex +import subprocess +import sys + +# pylint: disable=E0602 +Import("env") # noqa: F821 +try: + Import("projenv") # noqa: F821 +except Exception: # noqa: BLE001 -- not exported under -t nobuild + projenv = None + +# Precompile the force-includes plus the core headers and force-include +# the result into C++ src compiles only. Any failure stops the build. This +# script cannot import esphome: a test pins the values below to +# build_helpers/pch.py. +_HEADER_NAME = "esphome_pch.h" +_SOURCE_NAME = "esphome_pch_src.h" +_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) +_CONSUMER_FLAGS = ["-Winvalid-pch", "-Werror=invalid-pch", "-include", _HEADER_NAME] +_GUARD_TEXT = f"""\ +#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__) +#error "The precompiled header was not loaded" +#else +#include "{_SOURCE_NAME}" +#endif +""" +_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE) +# Before GCC 12 a .gch only loads at the address it was saved from. Checked +# for this PlatformIO platform, whose compiler is built for a random address +_OLD_GCC_PIO_PLATFORM = "libretiny" +_MOVABLE_PCH_GCC = 12 +# Relative: compiles run in the project root +_CC1_DIR = "pch_cc1" +_CC1PLUS_WRAPPER = """\ +#!/usr/bin/env -S "{python}" -IS +# Written by ESPHome: starts cc1plus without address randomisation, so it +# loads the precompiled header. See esphome/platformio/pch.py.script. +import ctypes +import sys + +real = {real!r} +libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib") +attr = ctypes.create_string_buffer(64) +libc.posix_spawnattr_init(attr) +# POSIX_SPAWN_SETEXEC | _POSIX_SPAWN_DISABLE_ASLR +libc.posix_spawnattr_setflags(attr, ctypes.c_short(0x0040 | 0x0100)) +# What os.fsencode does, without the import +encoding = sys.getfilesystemencoding() +argv = [real.encode(), *(a.encode(encoding, "surrogateescape") for a in sys.argv[1:]), None] +libc._NSGetEnviron.restype = ctypes.POINTER(ctypes.POINTER(ctypes.c_char_p)) +# Replaces this process on success; the return value is the error number +rc = libc.posix_spawn(None, argv[0], None, attr, (ctypes.c_char_p * len(argv))(*argv), + libc._NSGetEnviron().contents) +libc.strerror.restype = ctypes.c_char_p +sys.exit("ESPHome: cannot start " + real + ": " + libc.strerror(rc).decode()) +""" +# GCC bug 14940: before these releases the Windows loader maps a .gch only +# at its saved address. First fixed release per major, 16 on always fixed +_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)} +_WINDOWS_GCC_FIXED_DEFAULT = (16, 0) +_CXX_SUFFIXES = (".cpp", ".cc", ".cxx") +_HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".inc") +_INCLUDE_DIR_OPTIONS = ("-I", "-isystem", "-iquote", "-idirafter") +# Build output and library trees; package versions stand in for libraries +_SKIPPED_DIRS = (".pioenvs", ".piolibdeps", "managed_components") + + +def _include_closure(src_dir: Path, roots: list) -> dict: + """Include closure: src-relative name -> contents.""" + 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 (src_dir / rel).is_file(): + break + else: + continue + if rel in seen: + continue + data = seen[rel] = (src_dir / rel).read_bytes() + parent = posixpath.dirname(rel) + stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data)) + return seen + + +def _shell_arg(element) -> str: + """One compiler argument from one SCons element, as the real spawn + passes it (shlex would eat path backslashes on Windows).""" + arg = str(element) + if " " in arg or os.name == "nt": + return arg.replace('\\"', '"') + return (shlex.split(arg) or [""])[0] + + +def _src_envs(src_dir: Path) -> list: + """The environments the C++ src objects compile from: projenv, or the + ESP-IDF builder's own.""" + found = {} + src_dir = src_dir.resolve() + nodes = env.Flatten(env.get("PIOBUILDFILES", [])) # noqa: F821 + for node in nodes: + source = Path(str(node.sources[0])).resolve() + if source.suffix in _CXX_SUFFIXES and source.is_relative_to(src_dir): + found[id(node.env)] = node.env + if nodes and not found: + # Flags on projenv would reach no compile + raise RuntimeError("ESPHome: no C++ source takes the precompiled header") + return list(found.values()) or [projenv] # noqa: F821 + + +def _driver_output(cxx: str, option: str) -> str: + """What the driver prints for ``option``, or "" when it cannot run.""" + try: + result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510 + except OSError: + return "" + return result.stdout.strip() + + +def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str): + """Extra driver flags for an old GCC, or None where its .gch cannot be + used. Linux loads it as is. arm64 macOS loads every program at a random + address, so there the driver gets a -B directory whose cc1plus starts + the real one without that randomisation.""" + if pio_platform != _OLD_GCC_PIO_PLATFORM or sys.platform == "linux": + return [] + major = _driver_output(cxx, "-dumpversion").partition(".")[0] + if major.isdigit() and int(major) >= _MOVABLE_PCH_GCC: + return [] + if sys.platform != "darwin" or platform.machine() != "arm64": + return None + # The driver prints the bare name when it has no cc1plus of its own + real = _driver_output(cxx, "-print-prog-name=cc1plus") + if not os.path.isabs(real): + return None + wrapper = proj_dir / _CC1_DIR / "cc1plus" + wrapper.parent.mkdir(exist_ok=True) + real = os.path.normpath(real) + wrapper.write_text(_CC1PLUS_WRAPPER.format(python=sys.executable, real=real)) + wrapper.chmod(0o755) + return [f"-B{_CC1_DIR}/"] + + +def _gcc_loads_pch_here(cxx: str) -> bool: + """Whether a .gch loads here; ESPHOME_PCH_ENABLE=1, as esphome + normalizes it, insists.""" + if sys.platform != "win32" or os.environ.get("ESPHOME_PCH_ENABLE") == "1": + return True + # GCC ends the first --version line with its version; clang names itself + banner = _driver_output(cxx, "--version").partition("\n")[0] + if "clang" in banner.lower(): + return True + found = re.findall(r"\d+(?:\.\d+)+", banner) + version = tuple(int(p) for p in found[-1].split(".")) if found else () + if version and version[:2] >= _WINDOWS_GCC_FIXED.get(version[0], _WINDOWS_GCC_FIXED_DEFAULT): + return True + print( + f"ESPHome: GCC {'.'.join(map(str, version)) or 'of unknown version'} cannot load a " + "precompiled header on Windows (GCC bug 14940); compiling without it" + ) + return False + + +def _setup_pch() -> None: + if projenv is None: + if "nobuild" in COMMAND_LINE_TARGETS: # noqa: F821 + return + raise RuntimeError("ESPHome: no project environment for the pch") + # SCons compiles run in the project root, so the relative -include + # resolves; an absolute path would enter the ccache key + proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821 + src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821 + header = proj_dir / _HEADER_NAME + source = proj_dir / _SOURCE_NAME + gch = Path(f"{header}.gch") + sum_path = Path(f"{gch}.sum") + src_envs = _src_envs(src_dir) + + cxx = src_envs[0].subst("$CXX") + if not _gcc_loads_pch_here(cxx): + return + pio_platform = env.PioPlatform() # noqa: F821 + extra = _old_gcc_flags(proj_dir, pio_platform.name, cxx) + if extra is None: + print("ESPHome: this compiler cannot load a precompiled header here; compiling without it") + return + # 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 = [] + flag_it = iter( + _shell_arg(element) + for element in src_envs[0].subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] + ) + 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) + # Folded first and in order, as the compiler would have read them. An + # absolute path would enter the checksum, so it cannot be folded + if absolute := [name for name in include_headers if Path(name).is_absolute()]: + print( + f"ESPHome: the force-include of {absolute} prevents the precompiled " + "header; compiling without it" + ) + return + headers = [*include_headers, *_DEFAULT_HEADERS] + content = "".join(f'#include "{name}"\n' for name in headers) + + digest = hashlib.sha256() + digest.update(content.encode()) + digest.update(cxx.encode()) + # As ccache does: strip the build path so devices 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()) + # GCC does not check a .gch against its headers and PlatformIO package + # paths carry no version + for package in sorted(pio_platform.packages): + digest.update(f"{package}={pio_platform.get_package_version(package)}\0".encode()) + # Libraries carry their version in lib_deps + digest.update(repr(env.GetProjectOption("lib_deps", [])).encode()) # noqa: F821 + closure = _include_closure(src_dir, headers) + for rel in sorted(closure): + digest.update(rel.encode()) + digest.update(closure[rel]) + digest.update(b"\0") + # The ESP-IDF configuration: sdkconfig.h sits in the build output + for config in sorted(proj_dir.glob("sdkconfig.*")): + digest.update(config.read_bytes()) + # Project-local include dirs (rp2's lwip_override) hold generated + # headers outside src/ + prev = "" + for tok in flags: + inc = "" + for opt in _INCLUDE_DIR_OPTIONS: + if prev == opt: + inc = tok + elif tok.startswith(opt) and len(tok) > len(opt): + inc = tok[len(opt) :] + prev = tok + # Relative dirs are relative to the project, where compiles run + inc_dir = Path(os.path.normpath(proj_dir / inc)) + if ( + not inc + or inc_dir == proj_dir + or not inc_dir.is_relative_to(proj_dir) + or inc_dir.is_relative_to(src_dir) + or any(inc_dir.is_relative_to(proj_dir / name) for name in _SKIPPED_DIRS) + ): + continue + for local in sorted(inc_dir.rglob("*")): + if local.suffix in _HEADER_SUFFIXES and local.is_file(): + digest.update(str(local.relative_to(proj_dir)).encode()) + digest.update(local.read_bytes()) + digest.update(b"\0") + checksum = digest.hexdigest() + + # The ccache .sum file doubles as the freshness stamp + if not ( + gch.is_file() + and sum_path.is_file() + and sum_path.read_text(encoding="utf-8").strip() == checksum + ): + header.write_text(_GUARD_TEXT, encoding="utf-8") + source.write_text(content, encoding="utf-8") + result = subprocess.run( # noqa: PLW1510 + [cxx, "-x", "c++-header", *flags, *extra, "-c", str(source), "-o", str(gch)], + cwd=proj_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"ESPHome: could not compile the precompiled header:\n{result.stderr}" + ) + sum_path.write_text(checksum + "\n", encoding="utf-8") + + # Prepended: GCC only loads a .gch for the first -include + for src_env in src_envs: + src_env.Prepend(CXXFLAGS=[*extra, *_CONSUMER_FLAGS]) + print( + "ESPHome: Compiling with a precompiled header " + "(set ESPHOME_PCH_ENABLE=0 to disable)" + ) + + +_setup_pch() diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 5097239065f..fbb31ae4529 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -33,14 +33,18 @@ import time from typing import Any, NamedTuple from esphome.framework_helpers import ( + DownloadLockUnavailable, content_length, discard_partial_download, + downloaded_bytes, + extract_workers, failure_reason, resume_fetch_job, run_batch_downloads, - warn_prefetch_failures, + wait_for_download_lock, + warn_batch_failures, ) -from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree +from esphome.helpers import get_bool_env, rmtree _LOGGER = logging.getLogger(__name__) @@ -61,16 +65,10 @@ _RESOLVE_WORKERS = 8 # A hung child must not block the build; downloads resume on the next run _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 # the interpreter's own import-failure exit _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 _RESOLVE_FAILED = object() @@ -462,51 +460,54 @@ def _uri_jobs( 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: - """Wrap ``body`` so the shared destination is single-writer. - - Interleaved writers truncate each other's ``.part`` bytes (see - registry.py). The bounded poll observes Ctrl-C via the tracker; a - 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. + """Wrap ``body`` so the shared destination is single-writer (interleaved + writers truncate each other's ``.part``, see registry.py). A blown deadline + is a clean skip. On a lock-less filesystem a sha256-verified body runs + unlocked with one warning; a checksum-less one (``unlocked_ok=False``) fails. """ + 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: from filelock import FileLock, Timeout # fallback_to_soft would leave a stale marker on lock-less # filesystems that blocks every later build (see git.py) lock = FileLock(lock_path, fallback_to_soft=False) - deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT - while True: - try: - lock.acquire(timeout=_URI_LOCK_POLL) - break - except Timeout: - tracker(0) # raises when the batch is cancelled - if time.monotonic() >= deadline: - # Another process is fetching this same file; its copy - # is what the build needs (a large framework archive - # can hold the lock far longer than this deadline) - _LOGGER.debug("Leaving %s to its current downloader", dl_path.name) - return - except OSError as err: - if not unlocked_ok: - # A body with no checksum to catch interleaved corruption - raise - lock = None - _LOGGER.warning( - "Could not lock %s (%s); downloading unlocked", - dl_path.name, - err, - ) - break + try: + wait_for_download_lock(lock, tracker, on_disk, dl_path.name) + except Timeout: + # The holder's copy is what the build needs (a large + # framework archive can outlast this deadline) + _LOGGER.debug("Leaving %s to its current downloader", dl_path.name) + return + except DownloadLockUnavailable as err: + if not unlocked_ok: + # A body with no checksum to catch interleaved corruption + raise + lock = None + _LOGGER.warning( + "Could not lock %s (%s); downloading unlocked", + dl_path.name, + err, + ) try: 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) finally: if lock is not None: @@ -540,6 +541,7 @@ def _registry_fetch_job( dl_path, f"{dl_path}.esphome.lock", resume_fetch_job(url, dl_path, sha256=checksum, size=size), + size, ) def run(tracker: Any) -> None: @@ -571,9 +573,9 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any: tmp.replace(dl_path) def run(tracker: Any) -> None: - _serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)( - tracker - ) + _serialized_fetch_job( + dl_path, f"{tmp}.lock", promote, size, tmp, unlocked_ok=False + )(tracker) if dl_path.is_file(): # Won or lost, the race is over; staging files left behind # are dead weight PlatformIO's cache never prunes @@ -701,7 +703,7 @@ def _preinstall( would hang, not fail). Waves skip dependencies; the installed manifests feed the next wave. Any failure falls back to pio run. """ - workers = min(get_usable_cpu_count(), len(entries)) + workers = extract_workers(len(entries)) # One manager per worker (_install mutates instance state); built # serially because construction rewires the shared manager logger managers: SimpleQueue = SimpleQueue() @@ -890,7 +892,7 @@ def _prefetch(build_dir: Path, env: str) -> None: ) # PlatformIO retries failed packages itself, without resume failures = run_batch_downloads("Downloading PlatformIO packages", jobs) - warn_prefetch_failures(failures) + warn_batch_failures(failures, "Could not prefetch %s: %s") failed_names = {name for name, _ in failures} elif not groups and not unresolved: # Record the no-work run so the parent skips the next spawn. @@ -950,8 +952,10 @@ def main(argv: list[str]) -> int: """Subprocess entry point: ``prefetch ``.""" from esphome.core import CORE from esphome.log import setup_log + from esphome.platformio.runner import patch_registry_private_packages signal.signal(signal.SIGTERM, _sigterm) + patch_registry_private_packages() raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL") try: level = int(raw_level) if raw_level is not None else logging.INFO diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index 9538a28ff4d..326a587fc88 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -17,8 +17,13 @@ from esphome.framework_helpers import ( archive_extract_all, download_from_mirrors, download_with_resume, + downloaded_bytes, + extract_workers, + is_expected_fetch_error, rmdir, run_batch_downloads, + wait_for_download_lock, + warn_batch_failures, ) from esphome.net_retry import fetch_with_retry, http_request @@ -29,6 +34,18 @@ _REGISTRY_URL = ( ) +class Download(NamedTuple): + """A package archive to fetch and verify.""" + + url: str + sha256: str + size: int | None + + +# Looks a package's download up; called only when a download is needed +Resolver = Callable[[], Download] + + def get_systype() -> str: """The registry system tag for the current host. @@ -54,7 +71,7 @@ def get_systype() -> str: @cache -def registry_download(package: str, version: str) -> tuple[str, str, int | None]: +def registry_download(package: str, version: str) -> Download: """Resolve a package's download URL, sha256, and size via the registry. The metadata fetch goes through ``http_request``/``fetch_with_retry`` @@ -142,7 +159,7 @@ def registry_download(package: str, version: str) -> tuple[str, str, int | None] f"The package registry returned no download URL for " f"{package} {version}" ) - return (url, sha256, file.get("size")) + return Download(url, sha256, file.get("size")) raise EsphomeError( f"No {package} {version} build for this platform ({systype})" ) @@ -160,57 +177,98 @@ def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None: ) +class PackageSpec(NamedTuple): + """One registry package to install.""" + + name: str + version: str + dest: Path + mirrors: list[str] + expect: Collection[str] = () + + class _PendingArchive(NamedTuple): name: str version: str dest: Path + archive: Path url: str sha256: str size: int -def _already_installed(dest: Path) -> bool: +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 is_installed(dest: Path) -> bool: """Whether ``dest`` holds a completed install (extraction marker).""" return (dest / ".esphome_extracted").is_file() +def _batched_download_progress( + name: str, version: str, extract_progress: Callable[[float], None] +) -> Callable[[int], None]: + """Zero-tick tracker for a batched install; announces a real download + once, since the shared bar cannot move for it.""" + ticks = 0 + + def progress(done: int) -> None: + nonlocal ticks + ticks += 1 + # A verified archive credits itself in one tick; more than one + # means bytes are streaming, including a resumed .part + if ticks == 2: + _LOGGER.info("Re-downloading %s %s ...", name, version) + extract_progress(0.0) + + return progress + + def prefetch_packages( - packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path + packages: Collection[PackageSpec], + downloads_dir: Path, + resolvers: dict[str, Resolver] | None = None, ) -> None: """Download pending package archives in parallel under one combined bar. - ``packages`` holds ``(name, version, dest, mirrors)`` per package. Purely - an optimization: ``install_package`` verifies every archive and - re-downloads anything this pass left unfinished. Mirror overrides and - registry entries without a size stay on the sequential path so its - per-file bars remain trustworthy. Each fetch holds the same per-dest - lock as ``install_package``: the archive's ``.part`` file is shared, and - two concurrent writers would truncate each other's bytes. + ``packages`` holds one ``PackageSpec`` per package, the same list the + install pass takes; ``expect`` is unused here and ``resolvers`` replaces + the registry lookup by name. Purely an optimization: ``install_package`` + verifies every archive and re-downloads anything this pass left + unfinished. Mirror overrides and registry entries without a size stay on + the sequential path so its per-file bars remain trustworthy. Each fetch + holds the same per-dest lock as ``install_package``: the archive's + ``.part`` file is shared, and two concurrent writers would truncate each + other's bytes. """ - from filelock import FileLock + from filelock import FileLock, Timeout pending: list[_PendingArchive] = [] - seen: set[str] = set() - for name, version, dest, mirrors in packages: - if mirrors or (dest / ".esphome_extracted").is_file(): + seen: set[Path] = set() + for name, version, dest, mirrors, _expect in packages: + if mirrors or is_installed(dest): continue - archive_name = f"{name}-{version}" - if archive_name in seen: + archive = _archive_path(downloads_dir, name, version) + if archive in seen: # A duplicate entry would race itself between two workers continue - seen.add(archive_name) + seen.add(archive) + resolve = (resolvers or {}).get(name) or partial( + registry_download, name, version + ) try: - url, sha256, size = registry_download(name, version) + url, sha256, size = resolve() except EsphomeError as err: # The sequential install reports the real failure with context _LOGGER.debug("Prefetch resolve for %s failed: %s", name, err) continue if not size: continue - archive = downloads_dir / archive_name if archive.is_file() and archive.stat().st_size == size: continue - pending.append(_PendingArchive(name, version, dest, url, sha256, size)) + pending.append(_PendingArchive(name, version, dest, archive, url, sha256, size)) if len(pending) < 2: return downloads_dir.mkdir(parents=True, exist_ok=True) @@ -222,27 +280,43 @@ def prefetch_packages( def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None: 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 - # deleted the archive of) this package while we waited; - # re-downloading would orphan a fresh copy in downloads_dir - # no branch: the thread tracer misses the skip edge; both - # arms of _already_installed are pinned directly - if not _already_installed(entry.dest): # pragma: no branch - download_with_resume( - entry.url, - downloads_dir / f"{entry.name}-{entry.version}", - sha256=entry.sha256, - size=entry.size, - progress=tracker, - ) + + def on_disk() -> int: + if done := downloaded_bytes(entry.archive, entry.size): + return done + # The holder deletes the archive once it has installed it + return entry.size if is_installed(entry.dest) else 0 + + lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False) + try: + wait_for_download_lock(lock, tracker, on_disk, entry.name) + except Timeout: + # install_package waits on this same lock and verifies the + # holder's copy + _LOGGER.debug("Leaving %s to its current downloader", entry.name) + return + try: + if is_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( "Downloading packages", [(entry.name, entry.size, partial(_fetch, entry)) for entry in pending], ) for name, err in failures: - if isinstance(err, (EsphomeError, OSError)): + if is_expected_fetch_error(err): # Expected download failures: install_package retries this one # itself, with a visible bar _LOGGER.debug("Prefetch of %s failed: %s", name, err) @@ -259,13 +333,19 @@ def install_package( mirrors: list[str], downloads_dir: Path, expect: Collection[str], + resolve: Resolver | None = None, + extract_progress: Callable[[float], None] | None = None, ) -> None: """Download, verify, and extract one package if not already installed. The registry path is integrity-checked against the sha256 the registry publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}`` substitution) is trusted as configured. ``downloads_dir`` holds the - archive between runs so an interrupted download resumes. + archive between runs so an interrupted download resumes. ``resolve`` + replaces the registry lookup. + + ``extract_progress`` receives extraction fractions in [0, 1] instead of + the private per-file bars (see ``install_packages``). """ if not expect: # Layout validation before marker.touch() is the only guard against @@ -288,8 +368,11 @@ def install_package( rmdir(dest, msg=f"Clean up incomplete {name} install") # Persistent location so an interrupted download resumes across runs. downloads_dir.mkdir(parents=True, exist_ok=True) - archive = downloads_dir / f"{name}-{version}" - _LOGGER.info("Downloading %s %s ...", name, version) + archive = _archive_path(downloads_dir, name, version) + # Batched runs are announced by the batch header + batched = extract_progress is not None and archive.is_file() + if not batched: + _LOGGER.info("Downloading %s %s ...", name, version) if mirrors: _LOGGER.warning( "Downloading %s from a mirror override; checksum verification " @@ -300,12 +383,99 @@ def install_package( mirrors, {"VERSION": version, "SYSTEM": get_systype()}, archive ) else: - url, sha256, size = registry_download(name, version) - download_with_resume(url, archive, sha256=sha256, size=size) - _LOGGER.info("Extracting %s ...", name) - archive_extract_all(archive, dest, progress_header="Extracting") + url, sha256, size = ( + resolve() if resolve else registry_download(name, version) + ) + download_with_resume( + url, + archive, + sha256=sha256, + size=size, + # Zero ticks: the shared bar must never run backwards + progress=None + if extract_progress is None + else _batched_download_progress(name, version, extract_progress), + ) + if not batched: + _LOGGER.info("Extracting %s ...", name) + archive_extract_all( + archive, dest, progress_header="Extracting", progress=extract_progress + ) # Validate the layout before recording success, so an unexpected # package is never cached as a working install. _check_layout(name, dest, expect) marker.touch() archive.unlink(missing_ok=True) + + +def install_packages( + specs: Collection[PackageSpec], + downloads_dir: Path, + resolvers: dict[str, Resolver] | None = None, +) -> None: + """Install several packages; prefetched archives extract in parallel under + one shared bar, the rest take the sequential ``install_package`` path. + ``resolvers`` replaces the registry lookup by name; the first failure is + re-raised.""" + resolvers = resolvers or {} + pending: list[tuple[PackageSpec, int]] = [] + rest: list[PackageSpec] = [] + for spec in specs: + name, version, dest, mirrors, _expect = spec + archive = _archive_path(downloads_dir, name, version) + if is_installed(dest) or mirrors: + rest.append(spec) + continue + try: + # Sized, not hashed: install_package still verifies the archive + size = archive.stat().st_size + except FileNotFoundError: + rest.append(spec) + continue + pending.append((spec, size)) + if len(pending) < 2: + # One archive alone gains nothing from a pool + rest = list(specs) + pending = [] + + def _install(spec: PackageSpec, size: int, tracker: Callable[[int], None]) -> None: + name, version, dest, mirrors, expect = spec + install_package( + name, + version, + dest, + mirrors, + downloads_dir, + expect=expect, + resolve=resolvers.get(name), + extract_progress=lambda frac: tracker(int(frac * size)), + ) + + if pending: + workers = extract_workers(len(pending)) + _LOGGER.info( + "Extracting %d package archive(s) with %d worker(s): %s", + len(pending), + workers, + ", ".join(spec.name for spec, _ in pending), + ) + failures = run_batch_downloads( + "Extracting packages", + [(spec[0], size, partial(_install, spec, size)) for spec, size in pending], + max_workers=workers, + ) + if failures: + # The raised exception may not name the package; nothing runs + # behind this pass to redo the work + warn_batch_failures(failures, "Could not install %s: %s") + raise failures[0][1] + for name, version, dest, mirrors, expect in rest: + install_package( + name, + version, + dest, + mirrors, + downloads_dir, + expect=expect, + resolve=resolvers.get(name), + ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 9bb2205a909..b9fbdec38d0 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -2,7 +2,8 @@ Invoked via ``python -m esphome.platformio.runner`` instead of ``python -m platformio`` so that the patches (incremental rebuild -preservation, download retries) apply inside the subprocess. Running +preservation, download retries, skipping the private-package probe) apply +inside the subprocess. Running PlatformIO in a subprocess keeps its ``sys.path`` mutations and other global state from leaking into the ESPHome process. """ @@ -105,6 +106,16 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def patch_registry_private_packages() -> None: + """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. + + ESPHome never uses private packages, so the answer is always False. + """ + from platformio.registry.client import RegistryClient + + RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign] + + _IGNORE_LIB_WARNINGS = "(?:Hash|Update)" # Regex patterns matched against each line of PlatformIO output. Lines that # match are dropped by RedirectText before they reach the parent process. @@ -152,6 +163,7 @@ FILTER_PLATFORMIO_LINES = [ def main() -> int: patch_structhash() patch_file_downloader() + patch_registry_private_packages() # Wrap stdout/stderr with RedirectText before PlatformIO runs: # diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 97b32420da6..0eec532c7c7 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,9 +7,8 @@ import re import sys from typing import TYPE_CHECKING, Any -import platformdirs - from esphome.build_helpers.ccache import resolve_ccache_path +from esphome.build_helpers.tools_cache import PLATFORMIO_CCACHE, tools_cache_path from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError from esphome.framework_helpers import strip_win_long_path_prefix @@ -235,8 +234,9 @@ def _ccache_env() -> dict[str, str]: these values would hand it the wrong cache dir and a stale basedir. This mirrors ``_ccache_env()`` in ``esphome/espidf/framework.py``. The - cache lives under the machine-global ESPHome cache dir, so it is shared - across all projects and removed by ``esphome clean-all``. Unlike the + cache lives under the machine-global ESPHome cache dir, or where + ``ESPHOME_PLATFORMIO_CCACHE_DIR`` points (the containers set it to their + writable cache mount), and is removed by ``esphome clean-all``. Unlike the ESP-IDF path, ``CCACHE_DEPEND`` is not set: SCons compiles don't emit the depfiles depend mode needs, so ccache's default preprocessor mode is used. @@ -264,10 +264,7 @@ def _ccache_env() -> dict[str, str]: ) env["CCACHE_BASEDIR"] = str(Path(CORE.build_path).resolve()) defaults = { - "CCACHE_DIR": str( - Path(platformdirs.user_cache_dir("esphome", appauthor=False)) - / "platformio-ccache" - ), + "CCACHE_DIR": str(tools_cache_path(*PLATFORMIO_CCACHE)), "CCACHE_NOHASHDIR": "true", } env.update({k: v for k, v in defaults.items() if k not in os.environ}) @@ -289,6 +286,14 @@ def copy_ccache_script() -> None: ) +def copy_pch_script() -> None: + """Copy the precompiled header SCons script into the build dir.""" + copy_file_if_changed( + Path(__file__).parent / "pch.py.script", + CORE.relative_build_path("pch.py"), + ) + + def default_libdeps_dir() -> str: """The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package prefetch must resolve installed libraries against the same dir.""" @@ -330,6 +335,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: base_env = kwargs.pop("env", None) env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) + if CORE.is_configured: + from esphome.build_helpers.pch import ( + ccache_pch_env, + pch_forced, + pch_script_enabled, + ) + + if pch_script_enabled(): + env.update(ccache_pch_env()) + if pch_forced(): + # Normalized for the script, like ESPHOME_CCACHE_ENABLE + env["ESPHOME_PCH_ENABLE"] = "1" # The runner offers the out-of-flash tip but has no configured CORE, so # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same # variable; clear an inherited one so it cannot reach the wrong build. diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 9219914529b..adaa809d6e3 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -155,7 +155,7 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform - # The toolchain used for the build ("platformio" / "esp-idf") + # The toolchain used for the build ("platformio" / "esp-idf" / "host") self.toolchain = toolchain # The area of the node self.area = area diff --git a/esphome/util.py b/esphome/util.py index b8ffa048ca1..dd1998a7c29 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -179,15 +179,21 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) - def _emit_line(self, line: str) -> None: - line_without_ansi = ANSI_ESCAPE.sub("", line) - line_without_end = line_without_ansi.rstrip() - if ( + def _is_filtered(self, line: str) -> bool: + return ( self._filter_pattern is not None - and self._filter_pattern.match(line_without_end) is not None - ): - # Filter pattern matched, ignore the line + and self._filter_pattern.match(ANSI_ESCAPE.sub("", line).rstrip()) + is not None + ) + + def _splits_lines(self) -> bool: + """Whether output is handled line by line rather than passed through.""" + return self._filter_pattern is not None or bool(self._line_callbacks) + + def _emit_line(self, line: str) -> None: + if self._is_filtered(line): return + line_without_end = ANSI_ESCAPE.sub("", line).rstrip() self._write_color_replace(line) # Check for flash size error and provide helpful guidance @@ -233,7 +239,7 @@ class RedirectText: if not isinstance(s, str): s = s.decode() - if self._filter_pattern is not None or self._line_callbacks: + if self._splits_lines(): lines = (self._line_buffer + s).splitlines(True) # Every piece but the last ends with something # ``str.splitlines`` treats as a break, so only the last one can @@ -346,6 +352,10 @@ def run_external_command( return retval +# How a command starts another esphome, as a child of this one +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] + + def run_external_process(*cmd: str, **kwargs: Any) -> int | str: # Deferred: an OTA upload/logs run never spawns an external process. import subprocess diff --git a/esphome/writer.py b/esphome/writer.py index 0b9e7669ef9..0bd485bfd31 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -211,6 +211,18 @@ VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" +# Headers that must not be included bare from esphome.h or the clang-tidy +# all-headers file: X-macro files, headers main.cpp includes itself, and +# deprecated headers that only resolve when their new component is loaded. +ESPHOME_H_EXCLUDE = { + Path(ENTITY_TYPES_H_TARGET), + # main.cpp includes it after defining esphome_controllers() + Path("esphome/core/controller_dispatch.h"), + # moved to components/ring_buffer/, removed in 2026.11.0 + Path("esphome/core/ring_buffer.h"), + # build machinery, not user API + Path("esphome/core/pch_prefix.h"), +} ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -236,20 +248,9 @@ def copy_src_tree(): source_files_l.sort() # Build #include list for esphome.h - # X-macro files are included multiple times with different macro definitions - # and must not be included bare in esphome.h - # Deprecated headers that re-export from a relocated component must not be - # auto-included, since their #include of the new path only resolves when the - # new component is loaded by a consumer. - esphome_h_exclude = { - Path(ENTITY_TYPES_H_TARGET), - Path( - "esphome/core/ring_buffer.h" - ), # moved to components/ring_buffer/, removed in 2026.11.0 - } include_l = [] for target, _ in source_files_l: - if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: + if target.suffix in HEADER_FILE_EXTENSIONS and target not in ESPHOME_H_EXCLUDE: include_l.append(f'#include "{target}"') include_l.append("") include_s = "\n".join(include_l) @@ -609,11 +610,19 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): if idf_path.is_dir(): _LOGGER.info("Deleting %s", idf_path) rmtree(idf_path) + # The PlatformIO pch files live in the project root + from esphome.build_helpers.pch import PCH_ARTIFACT_DIRS, PCH_ARTIFACT_NAMES - # The idedata cache is derived from the build but lives under the data dir, - # not the build path, so it must be removed separately in both modes. - idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") - if idedata_cache.is_file(): + for name in PCH_ARTIFACT_NAMES: + CORE.relative_build_path(name).unlink(missing_ok=True) + for name in PCH_ARTIFACT_DIRS: + if (pch_dir := CORE.relative_build_path(name)).is_dir(): + rmtree(pch_dir) + + # idedata caches live under the data dir, not the build path; globbed + # so a future backend suffix cannot drift out of clean-all + idedata_dir = CORE.relative_internal_path("idedata") + for idedata_cache in idedata_dir.glob(f"{CORE.name}*.json"): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() diff --git a/esphome/yaml_edit.py b/esphome/yaml_edit.py new file mode 100644 index 00000000000..da66fa088fc --- /dev/null +++ b/esphome/yaml_edit.py @@ -0,0 +1,114 @@ +"""Rewrite single lines of a yaml file in place, located by the source +ranges the loader keeps, so quotes, comments, indentation and line endings +around them survive and nothing else in the file is touched.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +import stat + +from esphome.core import EsphomeError +from esphome.helpers import write_file +from esphome.types import ConfigType + +# A plain scalar with no yaml indicator, so an empty value, a block scalar +# (`>-`, `|`) or a flow collection never counts; then optional matching +# quotes and the trailer, where a comment needs whitespace before its `#` +PLAIN_SCALAR = r"[^\s#\"'>|&*!%@`\[\]{},]+" +TRAILER = r"(?P[\"']?){value}(?P=quote)(?P(?:\s+#.*)?\s*)$" + + +def read_text(path: Path) -> str: + """The file as written, line endings included; read_file would fold them.""" + try: + return path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError) as err: + raise EsphomeError(f"Error reading file {path}: {err}") from err + + +@dataclass +class LineEdit: + """One line to rewrite; ``old_line`` is what it held when located.""" + + path: Path + line: int + old_line: str + new_line: str + + +def field_line_re( + name: str, value: str | None = None, indent: str = r"\s*" +) -> re.Pattern[str]: + """Match ``name: value``, or ``name:`` with any plain scalar, at + ``indent``; the name may be quoted. Keeps the prefix, the quotes and the + trailer for rewrite.""" + scalar = PLAIN_SCALAR if value is None else re.escape(value) + prefix = rf"{indent}[\"']?{re.escape(name)}[\"']?\s*:\s*" + return re.compile(rf"^(?P{prefix}){TRAILER.format(value=scalar)}") + + +def rewrite(match: re.Match[str], value: str, quote: str | None = None) -> str: + """The matched line with ``value`` in place of the scalar; the source + quotes stay unless ``quote`` is given.""" + quote = match["quote"] if quote is None else quote + return f"{match['prefix']}{quote}{value}{quote}{match['trail']}" + + +def line_at(doc: Path, line_no: int) -> str: + lines = read_text(doc).splitlines() + if line_no >= len(lines): + raise EsphomeError(f"{doc}:{line_no + 1} changed since it was read") + return lines[line_no] + + +def source_of(mapping: ConfigType, name: str) -> tuple[Path, int] | None: + """The file and line ``name:`` was read from, None when validation added + it or a merge key brought it in from an anchor elsewhere. Mapping keys + keep their range through validation, values may not.""" + rng = getattr(next((k for k in mapping if k == name), None), "esp_range", None) + if rng is None: + return None + # An included mapping carries the `!include` line of its parent, so only + # a key in the same document can be placed against the mapping + if (own := getattr(mapping, "esp_range", None)) is None: + return None # a mapping built in code, its keys are not on its lines + if ( + rng.start_mark.document == own.start_mark.document + and not own.start_mark.line <= rng.start_mark.line <= own.end_mark.line + ): + return None + return Path(rng.start_mark.document), rng.start_mark.line + + +def write_keeping_mode(path: Path, text: str, like: Path | None = None) -> None: + """Write with the mode of ``like`` (default: the file itself) rather + than write_file's 0644; a 0600 secrets file stays 0600.""" + try: + mode = stat.S_IMODE((like or path).stat().st_mode) + except OSError as err: + raise EsphomeError(f"Could not read the mode of {like or path}: {err}") from err + try: + write_file(path, text, private=True) + except EsphomeError as err: + # write_file keeps the reason in the cause only + raise EsphomeError(f"{err}: {err.__cause__}") from err + try: + path.chmod(mode) + except OSError as err: + raise EsphomeError( + f"{path} was written but could not get its mode back: {err}" + ) from err + + +def rewritten_text(original: str, edits: list[LineEdit]) -> str: + """``original`` with the edits applied; every edit must still find the + line it was located on.""" + lines = original.splitlines(keepends=True) + for edit in edits: + text = lines[edit.line].rstrip("\r\n") if edit.line < len(lines) else None + if text != edit.old_line: + raise EsphomeError(f"{edit.path}:{edit.line + 1} changed since it was read") + lines[edit.line] = edit.new_line + lines[edit.line][len(text) :] + return "".join(lines) diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index 04075ec4c1f..573abf1b713 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -56,6 +56,7 @@ TXT_RECORD_PROJECT_VERSION = b"project_version" TXT_RECORD_NETWORK = b"network" TXT_RECORD_FRIENDLY_NAME = b"friendly_name" TXT_RECORD_VERSION = b"version" +TXT_RECORD_OTA_SIGNED = b"ota_signed" @dataclass @@ -76,6 +77,8 @@ class DiscoveredImport: project_name: str project_version: str network: str + # Defaults False so entries persisted before this field still load. + ota_signed: bool = False class DashboardBrowser(AsyncServiceBrowser): @@ -169,6 +172,7 @@ class DashboardImportDiscovery: project_name = info.properties[TXT_RECORD_PROJECT_NAME].decode() project_version = info.properties[TXT_RECORD_PROJECT_VERSION].decode() network = info.properties.get(TXT_RECORD_NETWORK, b"wifi").decode() + ota_signed = info.properties.get(TXT_RECORD_OTA_SIGNED) == b"1" friendly_name = info.properties.get(TXT_RECORD_FRIENDLY_NAME) if friendly_name is not None: friendly_name = friendly_name.decode() @@ -180,6 +184,7 @@ class DashboardImportDiscovery: project_name=project_name, project_version=project_version, network=network, + ota_signed=ota_signed, ) is_new = name not in self.import_state self.import_state[name] = discovered diff --git a/platformio.ini b/platformio.ini index afe8a47a0b1..fa7225fa4df 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - improv/Improv@1.2.7 ; improv_serial / esp32_improv + improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image @@ -78,7 +78,7 @@ lib_deps = ${common.lib_deps} ; api -- on the ESP-IDF framework this comes from the component registry ; instead (see esphome/idf_component.yml), so it is not in [common]. - esphome/noise-c@0.1.21 ; api + esphome/noise-c@0.1.30 ; api SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt @@ -143,9 +143,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -180,7 +180,7 @@ extra_scripts = ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz @@ -205,11 +205,11 @@ extra_scripts = extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 platform_packages = ; The framework-arduinopico package is no longer published to the PlatformIO ; registry, so install the framework straight from the GitHub release - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.1/rp2040-6.1.1.zip framework = arduino lib_deps = @@ -246,7 +246,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.21 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -259,6 +259,7 @@ build_flags = build_unflags = ${common.build_unflags} build_src_flags = -include Arduino.h +extra_scripts = pre:esphome/components/libretiny/scons_dont_inherit.py.script ; This is the common settings for the nRF52 using Zephyr. [common:nrf52-zephyr] @@ -643,7 +644,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.21 ; used by noise (api, ota) + esphome/noise-c@0.1.30 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/requirements.txt b/requirements.txt index cd3f7446f35..2a0b7b31f47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,12 +7,12 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.3 # from time +tzdata>=2026.4 # from time pyserial==3.5 -platformio==6.1.19 -esptool==5.3.1 -click==8.3.3 -aioesphomeapi==46.3.0 +platformio==6.2.0 +esptool==5.4.0 +click==8.5.0 +aioesphomeapi==46.6.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 @@ -27,12 +27,12 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.7 # native esp-idf toolchain global cache dir -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 +platformdirs==4.12.0 # native esp-idf toolchain global cache dir +ninja==1.13.2 # native host and esp8266 arduino toolchain build driver +filelock==4.0.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this -pyparsing >= 3.3.2 +pyparsing >= 3.3.3 # For autocompletion argcomplete>=3.7.2 diff --git a/requirements_dev.txt b/requirements_dev.txt index f2cf855d6b5..ee94a2401ae 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment -clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating +clang-format==13.0.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py clang-tidy==22.1.8 -yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating +yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py diff --git a/requirements_test.txt b/requirements_test.txt index 897445a4cbc..7f6d75f9470 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,9 @@ -pylint==4.0.8 -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 -pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.5.1 # also change in .github/workflows/ci.yml when updating +pylint==4.0.9 +flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.9 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +prek==0.5.3 # .github/workflows/ci.yml reads this pin +yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests pytest==9.1.1 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b8ee3066bd2..8bbb9ed7f9b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -81,6 +81,7 @@ ISOLATED_SIGNATURE_PREFIX = "isolated_" # NOTE: This should be kept in sync with both test_build_components and split_components_for_ci.py ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", + "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", @@ -96,6 +97,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index ca1c5736c89..db548ae4f62 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -28,6 +28,11 @@ class WireType(IntEnum): END_GROUP = 4 # groups (deprecated) FIXED32 = 5 # fixed32, sfixed32, float + @property + def cpp_name(self) -> str: + """The matching constant in proto.h.""" + return f"WIRE_TYPE_{self.name}" + # Generate with # protoc --python_out=script/api_protobuf -I esphome/components/api/ api_options.proto @@ -126,9 +131,10 @@ def camel_to_snake(name: str) -> str: return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() -def force_str(force: bool) -> str: - """Convert a boolean force value to string format for C++ code.""" - return str(force).lower() +def _encode_call(func: str, *args: str, force: bool = False) -> str: + """Emit one ProtoEncode call; every helper takes the cursor and returns it advanced.""" + suffix = "_force" if force else "" + return f"pos = ProtoEncode::{func}{suffix}({', '.join(('pos', *args))});" class TypeInfo(ABC): @@ -223,55 +229,39 @@ class TypeInfo(ABC): def class_member(self) -> str: return f"{self.cpp_type} {self.field_name}{{{self.default_value}}};" - @property - def decode_varint_content(self) -> str: - content = self.decode_varint - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" + def decode_case(self, body: str) -> str: + """Emit one decode_field() case, keyed on the field's wire tag.""" + return f"case proto_tag({self.number}, {self.wire_type.cpp_name}):\n" + indent( + f"{body}\nbreak;" + ) - decode_varint = None + # Expression that reads this field from `value`; None when the type is never decoded. + decode_expr: str | None = None + + def _decode_store(self, expr: str) -> str: + return f"this->{self.field_name} = {expr};" @property - def decode_length_content(self) -> str: - content = self.decode_length - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" - - decode_length = None - - @property - def decode_32bit_content(self) -> str: - content = self.decode_32bit - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" - - decode_32bit = None - - @property - def decode_64bit_content(self) -> str: - content = self.decode_64bit - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" - - decode_64bit = None + def decode_content(self) -> str | None: + """The decode_field() case for this field, or None when it is never decoded.""" + expr = self.decode_expr + return None if expr is None else self.decode_case(self._decode_store(expr)) # Mapping from encode_func to raw encode expression template. # When a forced field has a single-byte tag, the code generator emits # write_raw_byte(tag) + raw encode instead of the full encode_* method, # eliminating the zero-check branch and encode_field_raw indirection. # {value} is replaced with the actual field expression. - RAW_ENCODE_MAP: dict[str, str] = { - "encode_uint32": "ProtoEncode::encode_varint_raw(pos, {value});", - "encode_uint64": "ProtoEncode::encode_varint_raw_64(pos, {value});", - "encode_sint32": "ProtoEncode::encode_varint_raw_short(pos, encode_zigzag32({value}));", - "encode_sint64": "ProtoEncode::encode_varint_raw_64(pos, encode_zigzag64({value}));", - "encode_int64": "ProtoEncode::encode_varint_raw_64(pos, static_cast({value}));", - "encode_bool": "ProtoEncode::write_raw_byte(pos, {value} ? 0x01 : 0x00);", + RAW_ENCODE_MAP: dict[str, tuple[str, str]] = { + "encode_uint32": ("encode_varint_raw", "{value}"), + "encode_uint64": ("encode_varint_raw_64", "{value}"), + "encode_sint32": ("encode_varint_raw_short", "encode_zigzag32({value})"), + "encode_sint64": ("encode_varint_raw_64", "encode_zigzag64({value})"), + "encode_int64": ("encode_varint_raw_64", "static_cast({value})"), + "encode_bool": ("write_raw_byte", "{value} ? 0x01 : 0x00"), } + # Fixed32 value expression for the shared tag+fixed32 writer; None for other wire types + fixed32_value_template: str | None = None def _encode_with_precomputed_tag(self, value_expr: str) -> str | None: """Try to emit a precomputed-tag encode for a field. @@ -288,12 +278,17 @@ class TypeInfo(ABC): return None max_val = self.max_value # Only use RAW_ENCODE_MAP for forced fields or fields with max_value - raw_expr = None + raw = None if self.force or max_val is not None: - raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func) - if raw_expr is None: + raw = self.RAW_ENCODE_MAP.get(self.encode_func) + if raw is None: return None - body = f"ProtoEncode::write_raw_byte(pos, {tag});\n{raw_expr.format(value=value_expr)}" + func, arg = raw + body = ( + _encode_call("write_raw_byte", str(tag)) + + "\n" + + _encode_call(func, arg.format(value=value_expr)) + ) if self.force: return body # Non-forced with max_value: inline zero-check + raw encode @@ -314,23 +309,44 @@ class TypeInfo(ABC): return None # When max_len < 128, length varint is always 1 byte len_encode = ( - f"ProtoEncode::write_raw_byte(pos, static_cast({len_expr}));" + _encode_call("write_raw_byte", f"static_cast({len_expr})") if max_len is not None and max_len < 128 - else f"ProtoEncode::encode_varint_raw(pos, {len_expr});" + else _encode_call("encode_varint_raw", len_expr) ) + return "\n".join( + ( + _encode_call("write_raw_byte", str(tag)), + len_encode, + _encode_call("encode_raw", data_expr, len_expr), + ) + ) + + def _encode_fixed32_with_precomputed_tag(self, value: str) -> str | None: + """Single-byte tag fixed32 write, or None for other types and multi-byte tags.""" + tag = self.calculate_tag() + if self.fixed32_value_template is None or tag >= 128: + return None + value_expr = self.fixed32_value_template.format(value=value) + if self.force: + return _encode_call("write_tag_and_fixed32", str(tag), value_expr) return ( - f"ProtoEncode::write_raw_byte(pos, {tag});\n" - f"{len_encode}\n" - f"ProtoEncode::encode_raw(pos, {data_expr}, {len_expr});" + f"if (uint32_t raw = {value_expr}; raw != 0) [[likely]] {{\n" + f" {_encode_call('write_tag_and_fixed32', str(tag), 'raw')}\n" + "}" ) @property def encode_content(self) -> str: - if result := self._encode_with_precomputed_tag(f"this->{self.field_name}"): + value = f"this->{self.field_name}" + if result := self._encode_with_precomputed_tag(value): return result - if self.force: - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);" - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});" + if result := self._encode_fixed32_with_precomputed_tag(value): + return result + return _encode_call(self.encode_func, str(self.number), value, force=self.force) + + def encode_element(self, number: int, element: str) -> str: + """Encode one element of a repeated field; elements are always written.""" + return _encode_call(self.encode_func, str(number), element, force=True) encode_func = None @@ -550,17 +566,17 @@ def create_field_type_info( # For messages that decode (SOURCE_CLIENT or SOURCE_BOTH), use pointer # for zero-copy access to the receive buffer if needs_decode: - return PointerToBytesBufferType(field, None) + return PointerToBytesBufferType(field, needs_decode) # For SOURCE_SERVER (encode only), explicit annotation is still needed if get_field_opt(field, pb.pointer_to_buffer, False): - return PointerToBytesBufferType(field, None) + return PointerToBytesBufferType(field, needs_decode) return BytesType(field, needs_decode, needs_encode) # Special handling for string fields - use StringRef for zero-copy if field.type == 9: - return PointerToStringBufferType(field, None) + return PointerToStringBufferType(field, needs_decode) validate_field_type(field.type, field.name) if field.type == 11: @@ -605,7 +621,6 @@ class DoubleType(FixedSizeTypeMixin, TypeInfo): # Unsupported but defined for completeness cpp_type = "double" default_value = "0.0" - decode_64bit = "value.as_double()" encode_func = "encode_double" wire_type = WireType.FIXED64 # Uses wire type 1 according to protobuf spec @@ -631,10 +646,12 @@ class DoubleType(FixedSizeTypeMixin, TypeInfo): class FloatType(FixedSizeTypeMixin, TypeInfo): cpp_type = "float" default_value = "0.0f" - decode_32bit = "value.as_float()" + decode_expr = "value.as_float()" encode_func = "encode_float" wire_type = WireType.FIXED32 # Uses wire type 5 + fixed32_value_template = "float_to_raw({value})" + def dump(self, name: str) -> str: o = f'snprintf(buffer, sizeof(buffer), "%g", {name});\n' o += "out.append(buffer);" @@ -658,7 +675,7 @@ class Int64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" _varint_max_bits = 64 default_value = "0" - decode_varint = "static_cast(value)" + decode_expr = "static_cast(value.as_varint())" encode_func = "encode_int64" wire_type = WireType.VARINT # Uses wire type 0 @@ -679,7 +696,7 @@ class UInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "uint64_t" _varint_max_bits = 64 default_value = "0" - decode_varint = "value" + decode_expr = "value.as_varint()" encode_func = "encode_uint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -697,11 +714,11 @@ class UInt64Type(VarintTypeMixin, TypeInfo): return self._get_simple_size_calculation(name, force, "uint64") @property - def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802 + def RAW_ENCODE_MAP(self) -> dict[str, tuple[str, str]]: # noqa: N802 if self.mac_address: return { **TypeInfo.RAW_ENCODE_MAP, - "encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});", + "encode_uint64": ("encode_varint_raw_48bit", "{value}"), } return TypeInfo.RAW_ENCODE_MAP @@ -714,7 +731,7 @@ class Int32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" _varint_max_bits = 64 # int32 is sign-extended to 64 bits in protobuf default_value = "0" - decode_varint = "static_cast(value)" + decode_expr = "static_cast(value.as_varint())" encode_func = "encode_int32" wire_type = WireType.VARINT # Uses wire type 0 @@ -734,7 +751,6 @@ class Int32Type(VarintTypeMixin, TypeInfo): class Fixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint64_t" default_value = "0" - decode_64bit = "value.as_fixed64()" encode_func = "encode_fixed64" wire_type = WireType.FIXED64 # Uses wire type 1 @@ -760,7 +776,7 @@ class Fixed64Type(FixedSizeTypeMixin, TypeInfo): class Fixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint32_t" default_value = "0" - decode_32bit = "value.as_fixed32()" + decode_expr = "value.as_fixed32()" encode_func = "encode_fixed32" wire_type = WireType.FIXED32 # Uses wire type 5 @@ -769,15 +785,7 @@ class Fixed32Type(FixedSizeTypeMixin, TypeInfo): o += "out.append(buffer);" return o - @property - def encode_content(self) -> str: - tag = self.calculate_tag() - if self.force and tag < 128: - # Emit combined tag+value write: precomputed tag + direct memcpy - return f"ProtoEncode::write_tag_and_fixed32(pos, {tag}, this->{self.field_name});" - if self.force: - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);" - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});" + fixed32_value_template = "{value}" def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() @@ -797,7 +805,7 @@ class BoolType(VarintTypeMixin, TypeInfo): _varint_max_bits = 1 cpp_type = "bool" default_value = "false" - decode_varint = "value != 0" + decode_expr = "value.as_bool()" encode_func = "encode_bool" wire_type = WireType.VARINT # Uses wire type 0 @@ -817,7 +825,7 @@ class StringType(TypeInfo): default_value = "" reference_type = "std::string &" const_reference_type = "const std::string &" - decode_length = "value.as_string()" + decode_expr = "value.as_string()" encode_func = "encode_string" wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2 @@ -851,9 +859,12 @@ class StringType(TypeInfo): f"this->{self.field_name}_ref_.size()", ): return result - if self.force: - return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_, true);" - return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_);" + return _encode_call( + "encode_string", + str(self.number), + f"this->{self.field_name}_ref_", + force=self.force, + ) def dump(self, name): # If name is 'it', this is a repeated field element - always use string @@ -929,6 +940,9 @@ class MessageType(TypeInfo): def can_use_dump_field(cls) -> bool: return False + def encode_element(self, number: int, element: str) -> str: + return _encode_call("encode_sub_message", "buffer", str(number), element) + @property def cpp_type(self) -> str: return self._field.type_name[1:] @@ -951,15 +965,9 @@ class MessageType(TypeInfo): @property def encode_content(self) -> str: # Sub-message encoding needs buffer for backpatch/sync - return f"ProtoEncode::{self.encode_func}(pos, buffer, {self.number}, this->{self.field_name});" - - @property - def decode_length(self) -> str: - # Override to return None for message types because we can't use template-based - # decoding when the specific message type isn't known at compile time. - # Instead, we use the non-template decode_to_message() method which allows - # runtime polymorphism through virtual function calls. - return None + return _encode_call( + self.encode_func, "buffer", str(self.number), f"this->{self.field_name}" + ) @property def public_content(self) -> list[str]: @@ -976,19 +984,14 @@ class MessageType(TypeInfo): ) @property - def decode_length_content(self) -> str: - # Custom decode that doesn't use templates + def decode_content(self) -> str: + body = f"value.decode_to_message(this->{self.field_name});" if self._track_presence: # decode_to_message() cannot report failure, so setting the flag # afterwards only documents intent; a status-returning decode could # gate it for real without touching callers. - return ( - f"case {self.number}:\n" - f" value.decode_to_message(this->{self.field_name});\n" - f" this->has_{self.name} = true;\n" - f" break;" - ) - return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" + body += f"\nthis->has_{self.name} = true;" + return self.decode_case(body) def dump(self, name: str) -> str: return f"{name}.dump_to(out);" @@ -1027,7 +1030,7 @@ class BytesType(TypeInfo): reference_type = "std::string &" const_reference_type = "const std::string &" encode_func = "encode_bytes" - decode_length = "value.as_string()" + decode_expr = "value.as_string()" wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2 @property @@ -1058,9 +1061,13 @@ class BytesType(TypeInfo): f"this->{self.field_name}_ptr_", f"this->{self.field_name}_len_" ): return result - if self.force: - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_, true);" - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_);" + return _encode_call( + "encode_bytes", + str(self.number), + f"this->{self.field_name}_ptr_", + f"this->{self.field_name}_len_", + force=self.force, + ) def dump(self, name: str) -> str: ptr_dump = f"format_hex_pretty(this->{self.field_name}_ptr_, this->{self.field_name}_len_)" @@ -1127,16 +1134,12 @@ class PointerToBufferTypeBase(TypeInfo): def can_use_dump_field(cls) -> bool: return False + # Only here to make needs_decode required: the null string default keys off it, so a call + # site must not fall back on the base class default def __init__( - self, field: descriptor.FieldDescriptorProto, size: int | None = None + self, field: descriptor.FieldDescriptorProto, needs_decode: bool ) -> None: - super().__init__(field) - self.array_size = 0 - - @property - def decode_length(self) -> str | None: - # This is handled in decode_length_content - return None + super().__init__(field, needs_decode) @property def wire_type(self) -> WireType: @@ -1170,17 +1173,20 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): f"this->{self.field_name}", f"this->{self.field_name}_len" ): return result - if self.force: - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);" + return _encode_call( + "encode_bytes", + str(self.number), + f"this->{self.field_name}", + f"this->{self.field_name}_len", + force=self.force, + ) @property - def decode_length_content(self) -> str | None: - return f"""case {self.number}: {{ - this->{self.field_name} = value.data(); - this->{self.field_name}_len = value.size(); - break; - }}""" + def decode_content(self) -> str: + return self.decode_case( + f"this->{self.field_name} = value.data();\n" + f"this->{self.field_name}_len = value.size();", + ) def dump(self, name: str) -> str: return ( @@ -1214,34 +1220,53 @@ class PointerToStringBufferType(PointerToBufferTypeBase): def can_use_dump_field(cls) -> bool: return True + @property + def _starts_null(self) -> bool: + """A field that is only encoded, and skipped when empty, never has its pointer read + before it is set, so it can default to a null StringRef and the message constructs as + one zero fill. Any encode path that copies unconditionally must check this.""" + return not self._needs_decode and not self.force + @property def public_content(self) -> list[str]: + if self._starts_null: + return [ + f"StringRef {self.field_name}{{nullptr, 0}}; // null until set, encode only" + ] return [f"StringRef {self.field_name}{{}};"] @property def encode_content(self) -> str: max_len = self.max_data_length if max_len is not None and max_len < 128 and self.force: + assert not self._starts_null, ( + "unconditional copy of a field that may start null" + ) tag = self.calculate_tag() if tag < 128: - return f"ProtoEncode::encode_short_string_force(pos, {tag}, this->{self.field_name});" + return _encode_call( + "encode_short_string_force", str(tag), f"this->{self.field_name}" + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()", ): + assert not self._starts_null, ( + "unconditional copy of a field that may start null" + ) return result - if self.force: - return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}, true);" - return ( - f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name});" + return _encode_call( + "encode_string", + str(self.number), + f"this->{self.field_name}", + force=self.force, ) @property - def decode_length_content(self) -> str | None: - return f"""case {self.number}: {{ - this->{self.field_name} = StringRef(reinterpret_cast(value.data()), value.size()); - break; - }}""" + def decode_content(self) -> str: + return self.decode_case( + f"this->{self.field_name} = StringRef(value.data(), value.size());", + ) def dump(self, name: str) -> str: # Not used since we use dump_field, but required by abstract base class @@ -1310,14 +1335,13 @@ class PackedBufferTypeInfo(TypeInfo): ] @property - def decode_length_content(self) -> str: + def decode_content(self) -> str: """Store pointer to buffer and calculate count of packed varints.""" - return f"""case {self.number}: {{ - this->{self.field_name}_data_ = value.data(); - this->{self.field_name}_length_ = value.size(); - this->{self.field_name}_count_ = count_packed_varints(value.data(), value.size()); - break; - }}""" + return self.decode_case( + f"this->{self.field_name}_data_ = value.data();\n" + f"this->{self.field_name}_length_ = value.size();\n" + f"this->{self.field_name}_count_ = count_packed_varints(value.data(), value.size());", + ) @property def encode_content(self) -> str: @@ -1402,17 +1426,11 @@ class FixedArrayBytesType(TypeInfo): ] @property - def decode_length_content(self) -> str: - o = f"case {self.number}: {{\n" - o += " const std::string &data_str = value.as_string();\n" - o += f" this->{self.field_name}_len = data_str.size();\n" - o += f" if (this->{self.field_name}_len > {self.array_size}) {{\n" - o += f" this->{self.field_name}_len = {self.array_size};\n" - o += " }\n" - o += f" memcpy(this->{self.field_name}, data_str.data(), this->{self.field_name}_len);\n" - o += " break;\n" - o += "}" - return o + def decode_content(self) -> str: + return self.decode_case( + f"this->{self.field_name}_len = std::min(value.size(), {self.array_size});\n" + f"memcpy(this->{self.field_name}, value.data(), this->{self.field_name}_len);", + ) @property def encode_content(self) -> str: @@ -1421,9 +1439,13 @@ class FixedArrayBytesType(TypeInfo): f"this->{self.field_name}", f"this->{self.field_name}_len", max_len=max_len ): return result - if self.force: - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);" + return _encode_call( + "encode_bytes", + str(self.number), + f"this->{self.field_name}", + f"this->{self.field_name}_len", + force=self.force, + ) def dump(self, name: str) -> str: return f"out.append(format_hex_pretty({name}, {name}_len));" @@ -1471,7 +1493,7 @@ class UInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "uint32_t" _varint_max_bits = 32 default_value = "0" - decode_varint = "value" + decode_expr = "value.as_varint()" encode_func = "encode_uint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1494,13 +1516,21 @@ class UInt32Type(VarintTypeMixin, TypeInfo): class EnumType(VarintTypeMixin, TypeInfo): _varint_max_bits = 32 + def encode_element(self, number: int, element: str) -> str: + return _encode_call( + self.encode_func, + str(number), + f"static_cast({element})", + force=True, + ) + @property def cpp_type(self) -> str: return f"enums::{self._field.type_name[1:]}" @property - def decode_varint(self) -> str: - return f"static_cast<{self.cpp_type}>(value)" + def decode_expr(self) -> str: + return f"static_cast<{self.cpp_type}>(value.as_varint())" default_value = "" wire_type = WireType.VARINT # Uses wire type 0 @@ -1520,9 +1550,9 @@ class EnumType(VarintTypeMixin, TypeInfo): @property def encode_content(self) -> str: value_expr = f"static_cast(this->{self.field_name})" - if self.force: - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr}, true);" - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr});" + return _encode_call( + self.encode_func, str(self.number), value_expr, force=self.force + ) def dump(self, name: str) -> str: return f"out.append_p(proto_enum_to_string<{self.cpp_type}>({name}));" @@ -1547,7 +1577,7 @@ class EnumType(VarintTypeMixin, TypeInfo): class SFixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int32_t" default_value = "0" - decode_32bit = "value.as_sfixed32()" + decode_expr = "value.as_sfixed32()" encode_func = "encode_sfixed32" wire_type = WireType.FIXED32 # Uses wire type 5 @@ -1573,7 +1603,6 @@ class SFixed32Type(FixedSizeTypeMixin, TypeInfo): class SFixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int64_t" default_value = "0" - decode_64bit = "value.as_sfixed64()" encode_func = "encode_sfixed64" wire_type = WireType.FIXED64 # Uses wire type 1 @@ -1600,7 +1629,7 @@ class SInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" _varint_max_bits = 32 # zigzag encoding keeps it 32-bit default_value = "0" - decode_varint = "decode_zigzag32(static_cast(value))" + decode_expr = "decode_zigzag32(static_cast(value.as_varint()))" encode_func = "encode_sint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1621,7 +1650,7 @@ class SInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" _varint_max_bits = 64 default_value = "0" - decode_varint = "decode_zigzag64(value)" + decode_expr = "decode_zigzag64(value.as_varint())" encode_func = "encode_sint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -1701,9 +1730,9 @@ def _generate_inline_encode_block( lines = [] lines.append(f"auto &sub_msg = {element};") - lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") + lines.append(_encode_call("write_raw_byte", str(tag))) lines.append("uint8_t *len_pos = pos;") - lines.append("ProtoEncode::reserve_byte(pos);") + lines.append(_encode_call("reserve_byte")) # Generate inline field encoding for each sub-message field for field in sub_desc.field: @@ -1774,18 +1803,11 @@ class FixedArrayRepeatedType(TypeInfo): def _encode_element(self, element: str) -> str: """Helper to generate encode statement for a single element.""" - if isinstance(self._ti, EnumType): - return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" - # Repeated message elements use encode_sub_message (force=true is default) - if isinstance(self._ti, MessageType): - if _is_inline_encode(self._ti.cpp_type): - return _generate_inline_encode_block( - self.number, self._ti.cpp_type, element - ) - return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" - return ( - f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" - ) + if isinstance(self._ti, MessageType) and _is_inline_encode(self._ti.cpp_type): + return _generate_inline_encode_block( + self.number, self._ti.cpp_type, element + ) + return self._ti.encode_element(self.number, element) @property def cpp_type(self) -> str: @@ -2079,55 +2101,23 @@ class RepeatedTypeInfo(TypeInfo): return self._ti.wire_type @property - def decode_varint_content(self) -> str: - # Pointer fields don't support decoding - if self._use_pointer: - return None - content = self._ti.decode_varint - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) + def decode_expr(self) -> str | None: + return self._ti.decode_expr + + def _decode_store(self, expr: str) -> str: + return f"this->{self.field_name}.push_back({expr});" @property - def decode_length_content(self) -> str: + def decode_content(self) -> str | None: # Pointer fields don't support decoding if self._use_pointer: return None - content = self._ti.decode_length - if content is None and isinstance(self._ti, MessageType): - # Special handling for non-template message decoding - return f"case {self.number}: this->{self.field_name}.emplace_back(); value.decode_to_message(this->{self.field_name}.back()); break;" - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) - - @property - def decode_32bit_content(self) -> str: - # Pointer fields don't support decoding - if self._use_pointer: - return None - content = self._ti.decode_32bit - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) - - @property - def decode_64bit_content(self) -> str: - # Pointer fields don't support decoding - if self._use_pointer: - return None - content = self._ti.decode_64bit - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) + if isinstance(self._ti, MessageType): + return self.decode_case( + f"this->{self.field_name}.emplace_back();\n" + f"value.decode_to_message(this->{self.field_name}.back());" + ) + return super().decode_content @property def _ti_is_bool(self) -> bool: @@ -2135,15 +2125,7 @@ class RepeatedTypeInfo(TypeInfo): return isinstance(self._ti, BoolType) def _encode_element_call(self, element: str) -> str: - """Helper to generate encode call for a single element.""" - if isinstance(self._ti, EnumType): - return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" - # Repeated message elements use encode_sub_message (force=true is default) - if isinstance(self._ti, MessageType): - return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" - return ( - f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" - ) + return self._ti.encode_element(self.number, element) @property def encode_content(self) -> str: @@ -2152,7 +2134,7 @@ class RepeatedTypeInfo(TypeInfo): # Special handling for const char* elements (when container_no_template contains "const char") if "const char" in self._container_no_template: o = f"for (const char *it : *this->{self.field_name}) {{\n" - o += f" ProtoEncode::{self._ti.encode_func}(pos, {self.number}, it, strlen(it), true);\n" + o += f" {_encode_call(self._ti.encode_func, str(self.number), 'it', 'strlen(it)', force=True)}\n" else: o = f"for (const auto &it : *this->{self.field_name}) {{\n" o += f" {self._encode_element_call('it')}\n" @@ -2538,10 +2520,7 @@ def build_message_type( ) -> tuple[str, str, str]: public_content: list[str] = [] protected_content: list[str] = [] - decode_varint: list[str] = [] - decode_length: list[str] = [] - decode_32bit: list[str] = [] - decode_64bit: list[str] = [] + decode: list[str] = [] encode: list[str] = [] dump: list[str] = [] size_calc: list[str] = [] @@ -2557,8 +2536,8 @@ def build_message_type( # Get source direction to determine if we need decode/encode methods source = message_source_map[desc.name] - needs_decode = source in (SOURCE_BOTH, SOURCE_CLIENT) - needs_encode = source in (SOURCE_BOTH, SOURCE_SERVER) + needs_decode = message_needs_decode(source) + needs_encode = message_needs_encode(source) # Add MESSAGE_TYPE method if this is a service message if message_id is not None: @@ -2670,22 +2649,8 @@ def build_message_type( if field.options.HasExtension(pb.field_ifdef): field_ifdef = field.options.Extensions[pb.field_ifdef] - if ti.decode_varint_content: - decode_varint.extend( - wrap_with_ifdef(ti.decode_varint_content, field_ifdef) - ) - if ti.decode_length_content: - decode_length.extend( - wrap_with_ifdef(ti.decode_length_content, field_ifdef) - ) - if ti.decode_32bit_content: - decode_32bit.extend( - wrap_with_ifdef(ti.decode_32bit_content, field_ifdef) - ) - if ti.decode_64bit_content: - decode_64bit.extend( - wrap_with_ifdef(ti.decode_64bit_content, field_ifdef) - ) + if case := ti.decode_content: + decode.extend(wrap_with_ifdef(case, field_ifdef)) if ti.dump_content: # Check for field_ifdef option for dump as well field_ifdef = None @@ -2695,50 +2660,23 @@ def build_message_type( dump.extend(wrap_with_ifdef(ti.dump_content, field_ifdef)) cpp = "" - if decode_varint: - o = f"bool {desc.name}::decode_varint(uint32_t field_id, proto_varint_value_t value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_varint), " ") + "\n" - o += " default: return false;\n" + if decode: + o = f"void {desc.name}::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {{\n" + o += f" auto &msg = *static_cast<{desc.name} *>(self);\n" + o += " const ProtoFieldValue value(data, scalar);\n" + o += " switch (tag) {\n" + o += indent("\n".join(decode), " ").replace("this->", "msg.") + "\n" o += " }\n" - o += " return true;\n" o += "}\n" cpp += o - prot = "bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;" - protected_content.insert(0, prot) - if decode_length: - o = f"bool {desc.name}::decode_length(uint32_t field_id, ProtoLengthDelimited value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_length), " ") + "\n" - o += " default: return false;\n" - o += " }\n" - o += " return true;\n" - o += "}\n" - cpp += o - prot = "bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;" - protected_content.insert(0, prot) - if decode_32bit: - o = f"bool {desc.name}::decode_32bit(uint32_t field_id, Proto32Bit value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_32bit), " ") + "\n" - o += " default: return false;\n" - o += " }\n" - o += " return true;\n" - o += "}\n" - cpp += o - prot = "bool decode_32bit(uint32_t field_id, Proto32Bit value) override;" - protected_content.insert(0, prot) - if decode_64bit: - o = f"bool {desc.name}::decode_64bit(uint32_t field_id, Proto64Bit value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_64bit), " ") + "\n" - o += " default: return false;\n" - o += " }\n" - o += " return true;\n" - o += "}\n" - cpp += o - prot = "bool decode_64bit(uint32_t field_id, Proto64Bit value) override;" + prot = "static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar);" protected_content.insert(0, prot) + if not fixed_vector_fields: + public_content.append( + "void decode(const uint8_t *buffer, size_t length) {\n" + " ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);\n" + "}" + ) # Generate custom decode() override for messages with FixedVector fields if fixed_vector_fields: @@ -2748,8 +2686,8 @@ def build_message_type( for field_name, field_number in fixed_vector_fields: o += f" uint32_t count_{field_name} = ProtoDecodableMessage::count_repeated_field(buffer, length, {field_number});\n" o += f" this->{field_name}.init(count_{field_name});\n" - # Call parent decode to populate the fields - o += " ProtoDecodableMessage::decode(buffer, length);\n" + # Then the shared loop fills them + o += " ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);\n" o += "}\n" cpp += o # Generate the decode() declaration in header (public method) @@ -2784,28 +2722,36 @@ def build_message_type( ) for line in encode ] - o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {{\n" + o += f" const auto &msg = *static_cast(self);\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" - o += indent("\n".join(encode_debug)) + "\n" + o += indent("\n".join(encode_debug)).replace("this->", "msg.") + "\n" o += " return pos;\n" o += "}\n" cpp += o - prot = ( - "uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;" + public_content.append( + "static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);" + ) + public_content.append( + "uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {\n" + " return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);\n" + "}" ) - public_content.append(prot) # If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc and not is_inline_only: - o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" + o = f"{speed_attr}uint32_t {desc.name}::calc_size_msg(const void *self) {{\n" + o += f" const auto &msg = *static_cast(self);\n" o += " uint32_t size = 0;\n" - o += indent("\n".join(size_calc)) + "\n" + o += indent("\n".join(size_calc)).replace("this->", "msg.") + "\n" o += " return size;\n" o += "}\n" cpp += o - prot = "uint32_t calculate_size() const;" - public_content.append(prot) + public_content.append("static uint32_t calc_size_msg(const void *self);") + public_content.append( + "uint32_t calculate_size() const { return calc_size_msg(this); }" + ) # If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used # dump_to method declaration in header @@ -2900,6 +2846,23 @@ def get_field_opt( return field.options.Extensions[opt] +def message_needs_decode(source: int) -> bool: + return source in (SOURCE_BOTH, SOURCE_CLIENT) + + +def message_needs_encode(source: int) -> bool: + return source in (SOURCE_BOTH, SOURCE_SERVER) + + +def is_decodable_class(desc: descriptor.DescriptorProto, source: int) -> bool: + """Whether the generated class derives from ProtoDecodableMessage: decoded, and either on a + decodable base class or with at least one live field.""" + return message_needs_decode(source) and ( + get_base_class(desc) is not None + or any(not field.options.deprecated for field in desc.field) + ) + + def get_base_class(desc: descriptor.DescriptorProto) -> str | None: """Get the base_class option from a message descriptor.""" if not desc.options.HasExtension(pb.base_class): @@ -3001,11 +2964,11 @@ def build_base_class( # Determine if any message using this base class needs decoding/encoding needs_decode = any( - message_source_map.get(msg.name, SOURCE_BOTH) in (SOURCE_BOTH, SOURCE_CLIENT) + message_needs_decode(message_source_map.get(msg.name, SOURCE_BOTH)) for msg in messages ) needs_encode = any( - message_source_map.get(msg.name, SOURCE_BOTH) in (SOURCE_BOTH, SOURCE_SERVER) + message_needs_encode(message_source_map.get(msg.name, SOURCE_BOTH)) for msg in messages ) @@ -3439,6 +3402,7 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint # Generate message types with base class information # Simple grouping by ifdef + decodable_messages: list[tuple[str, str | None]] = [] current_ifdef = None for m in mt: @@ -3455,6 +3419,8 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint continue s, c, dc = build_message_type(m, base_class_fields, message_source_map) + if is_decodable_class(m, message_source_map[m.name]): + decodable_messages.append((m.name, message_ifdef_map.get(m.name))) msg_ifdef = message_ifdef_map.get(m.name) # Handle ifdef changes @@ -3481,6 +3447,22 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint cpp += "#endif\n" dump_cpp += "#endif\n" + # decode() passes decode_field explicitly, so without the dump virtuals no decodable message + # may carry a vtable; a build at any level below VERY_VERBOSE proves it + cpp += "#ifndef HAS_PROTO_MESSAGE_DUMP\n" + assert_ifdef = None + for name, msg_ifdef in decodable_messages: + if msg_ifdef != assert_ifdef: + if assert_ifdef is not None: + cpp += "#endif\n" + if msg_ifdef is not None: + cpp += _make_ifdef_line(msg_ifdef) + "\n" + assert_ifdef = msg_ifdef + cpp += f'static_assert(!std::is_polymorphic_v<{name}>, "decodable messages carry no vtable");\n' + if assert_ifdef is not None: + cpp += "#endif\n" + cpp += "#endif\n" + content += """\ } // namespace esphome::api diff --git a/script/build_helpers.py b/script/build_helpers.py index b4b25924c3b..f0b354a8a7f 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -22,8 +22,8 @@ from esphome.__main__ import command_compile, parse_args from esphome.config import validate_config from esphome.const import CONF_PLATFORM from esphome.core import CORE +from esphome.host.toolchain import get_elf_path from esphome.loader import get_component, get_platform -from esphome.platformio.toolchain import get_idedata from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest # This must coincide with the version in /platformio.ini @@ -220,7 +220,7 @@ def create_host_config( friendly_name: str, libraries: str | list[str], includes: list[str], - platformio_options: dict, + build_flags: list[str], ) -> dict: """Create an ESPHome host configuration for C++ builds. @@ -229,7 +229,7 @@ def create_host_config( friendly_name: Human-readable name libraries: PlatformIO library specification(s) includes: List of include folders for the build - platformio_options: Dict of platformio_options to set + build_flags: Compiler/linker flags for the build Returns: Configuration dict for ESPHome @@ -239,7 +239,7 @@ def create_host_config( "name": config_name, "friendly_name": friendly_name, "libraries": libraries, - "platformio_options": platformio_options, + "build_flags": build_flags, "includes": includes, }, HOST_KEY: {}, @@ -403,13 +403,12 @@ def compile_and_get_binary( return EXIT_COMPILE_ERROR, None # After a successful compilation, locate the executable: - idedata = get_idedata(config) - if idedata is None: + program_path = get_elf_path() + if not program_path.is_file(): print("Cannot find executable") return EXIT_NO_EXECUTABLE, None - program_path: str = idedata.raw["prog_path"] - return EXIT_OK, program_path + return EXIT_OK, str(program_path) def build_and_run( @@ -419,7 +418,7 @@ def build_and_run( config_prefix: str, friendly_name: str, libraries: str | list[str], - platformio_options: dict, + build_flags: list[str], main_entry: str, label: str = "build", build_only: bool = False, @@ -438,7 +437,7 @@ def build_and_run( config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench") friendly_name: Human-readable name for the config libraries: PlatformIO library specification(s) - platformio_options: PlatformIO options dict + build_flags: Compiler/linker flags for the build main_entry: Name of the main entry file (e.g. "main.cpp") label: Label for log messages build_only: If True, print binary path and return without running @@ -488,7 +487,7 @@ def build_and_run( config_name: str = f"{config_prefix}-" + hash_components(components) config = create_host_config( - config_name, friendly_name, libraries, includes, platformio_options + config_name, friendly_name, libraries, includes, build_flags ) exit_code, program_path = compile_and_get_binary( diff --git a/script/check_idf_py_equivalence.py b/script/check_idf_py_equivalence.py new file mode 100755 index 00000000000..fa349e223a4 --- /dev/null +++ b/script/check_idf_py_equivalence.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Check that a native ESP-IDF build tree is what idf.py itself would produce. + +ESPHome runs cmake and ninja directly with the arguments idf.py uses. This +runs the real ``idf.py reconfigure`` and ``idf.py build`` on a finished tree +(in place: CMake rejects a moved cache) and fails if either one changes the +cache, the generated build files or the firmware, or recompiles anything. +It catches drift when the pinned ESP-IDF version changes what idf.py does. +The color and ``size`` environment only change what is printed, so those +parts of the contract are pinned by the unit tests instead. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +WATCHED = ( + "build/CMakeCache.txt", + "build/build.ninja", + "build/compile_commands.json", + "build/project_description.json", + "build/config/sdkconfig.h", + "build/bootloader/bootloader.bin", +) +# Ninja logs whose outputs mean real work when their recorded mtime changes. +# The top level re-logs the bootloader step's byproducts on every build, so +# the bootloader is judged by its own sub-build log instead. +TOP_NINJA_LOG = "build/.ninja_log" +NINJA_LOGS = (TOP_NINJA_LOG, "build/bootloader/.ninja_log") +BOOTLOADER_BYPRODUCT = re.compile(r"(^|/build/)bootloader/") +WORK_SUFFIXES = (".obj", ".o", ".a", ".elf", ".map", ".bin", ".ld") +DEFAULT_GLOB = "tests/test_build_components/build/.esphome/build/*" + + +def _digest(path: Path) -> str | None: + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + + +def watched(name: str) -> list[str]: + """Files that change if idf.py configures or builds differently.""" + return [*WATCHED, f"build/{name}.elf", f"build/{name}.bin"] + + +def _snapshot(build_path: Path, name: str) -> dict[str, str | None]: + return {f: _digest(build_path / f) for f in watched(name)} + + +def _ninja_mtimes(build_path: Path) -> dict[tuple[str, str], str]: + """(log, output) -> recorded mtime; compaction-safe, unlike a line count.""" + mtimes = {} + for name in NINJA_LOGS: + log = build_path / name + lines = log.read_text(encoding="utf-8").splitlines() if log.is_file() else [] + for fields in (line.split("\t") for line in lines if not line.startswith("#")): + if len(fields) >= 4 and ( + name != TOP_NINJA_LOG or not BOOTLOADER_BYPRODUCT.search(fields[3]) + ): + mtimes[name, fields[3]] = fields[2] + return mtimes + + +def _log_problems(build_path: Path, mtimes: dict[tuple[str, str], str]) -> list[str]: + """A missing or unparsable ninja log would otherwise compare as unchanged.""" + problems = [] + for log in NINJA_LOGS: + if not (build_path / log).is_file(): + problems.append(f"missing {log}") + elif not any(k[0] == log and k[1].endswith(WORK_SUFFIXES) for k in mtimes): + problems.append(f"no build entries parsed from {log}") + return problems + + +def _setup_core(build_path: Path, description: dict) -> tuple[str, str]: + """Point CORE at the tree so ESPHome resolves the same IDF env as the build.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT + import esphome.config_validation as cv + from esphome.core import CORE + + name = description["project_name"] + version = Path(description["idf_path"]).name + CORE.config_path = build_path.parents[2] / f"{name}.yaml" + CORE.build_path = build_path + CORE.name = name + CORE.data[KEY_ESP32] = { + KEY_IDF_VERSION: cv.Version.parse(version), + KEY_VARIANT: description["target"].upper(), + } + return name, version + + +def check(build_path: Path) -> list[str]: + """Return the problems found in one build tree.""" + # pylint: disable=protected-access + from esphome.espidf import toolchain + + description = json.loads( + (build_path / "build" / "project_description.json").read_text(encoding="utf-8") + ) + name, version = _setup_core(build_path, description) + env = toolchain._get_idf_env(version) + python = toolchain._get_idf_tool("python") + idf_py = toolchain._get_idf_path(version) / "tools" / "idf.py" + sdkconfig = build_path / f"sdkconfig.{name}" + sdkconfig_args = ["-D", f"SDKCONFIG={sdkconfig}"] if sdkconfig.is_file() else [] + + # CMake writes a different build.ninja on a tree's first configure than on + # a reconfigure, so the baseline is ESPHome's own reconfigure and build. + if (rc := toolchain.run_reconfigure()) != 0: + return [f"ESPHome's CMake configure failed with exit code {rc}"] + if (rc := toolchain._run_ninja("all", verbose=False, jobs=None)) != 0: + return [f"ESPHome's ninja build failed with exit code {rc}"] + before = _snapshot(build_path, name) + mtimes_before = _ninja_mtimes(build_path) + # A moved or renamed output would otherwise compare as "unchanged". + problems = [f"missing {f}" for f, digest in before.items() if digest is None] + if problems := problems + _log_problems(build_path, mtimes_before): + return problems + for action in ("reconfigure", "build"): + result = subprocess.run( + [python, str(idf_py), *sdkconfig_args, action], + cwd=build_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [f"idf.py {action} failed:\n{result.stdout}{result.stderr}"] + after = _snapshot(build_path, name) + mtimes_after = _ninja_mtimes(build_path) + problems = [f"idf.py changed {f}" for f in before if before[f] != after[f]] + problems += _log_problems(build_path, mtimes_after) + for key in sorted(mtimes_before.keys() | mtimes_after.keys()): + log, out = key + if not out.endswith(WORK_SUFFIXES): + continue + if key not in mtimes_after: + problems.append(f"idf.py dropped {out} from {log}") + elif mtimes_before.get(key) != mtimes_after[key]: + problems.append(f"idf.py rebuilt {out}") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument( + "build_paths", + nargs="*", + type=Path, + help=f"ESPHome build dirs (default: the first native ESP-IDF tree in {DEFAULT_GLOB})", + ) + args = parser.parse_args() + paths = args.build_paths or sorted(REPO_ROOT.glob(DEFAULT_GLOB)) + # Not resolved: SDKCONFIG must be spelled as the build spelled it. + trees = [ + p + for p in paths + if (p / "build" / "project_description.json").is_file() + and (p / "build" / "CMakeCache.txt").is_file() + ] + if rejected := [p for p in args.build_paths if p not in trees]: + for path in rejected: + print(f"{path}: not a configured native ESP-IDF build tree") + return 1 + if not trees: + print("No native ESP-IDF build tree found") + return 1 + if not args.build_paths: + # The contract does not depend on the target, so one tree is enough. + trees = trees[:1] + + failed = False + for tree in trees: + problems = check(tree) + print(f"{tree}: {'OK' if not problems else 'DIFFERS'}") + for problem in problems: + print(f" {problem}") + failed |= bool(problems) + if failed: + print( + "The direct cmake/ninja build no longer matches idf.py. Compare " + "_IdfPyContract and its users in esphome/espidf/toolchain.py with the " + "pinned ESP-IDF tools/idf_py_actions." + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/ci-custom.py b/script/ci-custom.py index f481fda8609..aaf177c941d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -3,6 +3,7 @@ import argparse import codecs import collections +from collections.abc import Iterator import fnmatch import functools import os.path @@ -163,7 +164,21 @@ def lint_post_check(func): return func -def lint_re_check(regex, **kwargs): +def _nolint_in_match(content, haystack, match, mask): + """With masking, only a trailing comment counts: the raw span still holds string contents, and + the masked text is blank exactly where comments and strings were, so NOLINT must sit after the + last real code character of the span.""" + raw = content[match.start() : match.end()] + if not mask: + return "NOLINT" in raw + masked = haystack[match.start() : match.end()].rstrip() + return "NOLINT" in raw[len(masked) :] + + +def lint_re_check(regex, mask=False, prefilter=None, **kwargs): + """mask=True blanks comments and string literals first so prose about the pattern is not reported; + the masked text keeps its length, so match offsets still index the original content. + prefilter is a literal every match must contain, checked before the costlier masking.""" flags = kwargs.pop("flags", re.MULTILINE) prog = re.compile(regex, flags) decor = lint_content_check(**kwargs) @@ -172,8 +187,11 @@ def lint_re_check(regex, **kwargs): @functools.wraps(func) def new_func(fname, content): errs = [] - for match in prog.finditer(content): - if "NOLINT" in match.group(0): + if prefilter is not None and prefilter not in content: + return errs + haystack = _mask_cpp_comments_strings(content) if mask else content + for match in prog.finditer(haystack): + if _nolint_in_match(content, haystack, match, mask): continue lineno = content.count("\n", 0, match.start()) + 1 substr = content[: match.start()] @@ -247,6 +265,9 @@ def lint_ext_check(fname): "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md", + # Symlinks to the shared .agents/skills directory + ".claude/skills", + ".github/skills", # Symlink to the real wifi scan_list.h so the test stub cannot drift "tests/integration/fixtures/external_components/wifi/scan_list.h", ] @@ -294,6 +315,9 @@ def highlight(s): "esphome/components/socket/headers.h", "esphome/core/defines.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): @@ -522,21 +546,67 @@ def lint_conf_matches(fname, match): CONF_RE = r'^(CONF_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' with codecs.open("esphome/const.py", "r", encoding="utf-8") as const_f_handle: constants_content = const_f_handle.read() +with codecs.open( + "esphome/components/const/__init__.py", "r", encoding="utf-8" +) as component_const_f_handle: + component_constants_content = component_const_f_handle.read() + +# The two canonical homes for shared constants: esphome/const.py (core, frozen) and +# esphome/components/const/__init__.py (shared by components). A constant defined in +# either must be imported from there rather than redefined in a component. +CONST_HOMES = ["esphome/const.py", "esphome/components/const/__init__.py"] + CONSTANTS = [m.group(1) for m in re.finditer(CONF_RE, constants_content, re.MULTILINE)] +COMPONENT_CONSTANTS = [ + m.group(1) for m in re.finditer(CONF_RE, component_constants_content, re.MULTILINE) +] CONSTANTS_USES = collections.defaultdict(list) -@lint_re_check(CONF_RE, include=["*.py"], exclude=["esphome/const.py"]) +def _const_home_error(name, core_constants, component_constants): + """Return an error if the constant already lives in one of the canonical homes.""" + if name in core_constants: + return ( + f"Constant {highlight(name)} has already been defined in const.py - " + "please import the constant from const.py directly." + ) + if name in component_constants: + return ( + f"Constant {highlight(name)} has already been defined in " + "esphome/components/const/__init__.py - please import the constant from " + "esphome.components.const directly." + ) + return None + + +@lint_re_check(CONF_RE, include=["*.py"], exclude=CONST_HOMES) def lint_conf_from_const_py(fname, match): name = match.group(1) - if name not in CONSTANTS: + err = _const_home_error(name, CONSTANTS, COMPONENT_CONSTANTS) + if err is None: CONSTANTS_USES[name].append(fname) - return None - return ( - f"Constant {highlight(name)} has already been defined in const.py - " - "please import the constant from const.py directly." - ) + return err + + +UNIT_RE = r'^(UNIT_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' +UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, constants_content, re.MULTILINE) +] +COMPONENT_UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, component_constants_content, re.MULTILINE) +] + +UNIT_CONSTANTS_USES = collections.defaultdict(list) + + +@lint_re_check(UNIT_RE, include=["*.py"], exclude=CONST_HOMES) +def lint_unit_from_const_py(fname, match): + name = match.group(1) + err = _const_home_error(name, UNIT_CONSTANTS, COMPONENT_UNIT_CONSTANTS) + if err is None: + UNIT_CONSTANTS_USES[name].append(fname) + return err RAW_PIN_ACCESS_RE = ( @@ -704,10 +774,24 @@ def lint_constants_usage(): return errs +@lint_post_check +def lint_unit_constants_usage(): + errs = [] + for constant, uses in UNIT_CONSTANTS_USES.items(): + if len(uses) < 3: + continue + errs.append( + f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " + f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " + "See https://developers.esphome.io/contributing/code/#python" + ) + return errs + + # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1017 +CONST_PY_MAX_CONF = 1016 @lint_content_check(include=["esphome/const.py"]) @@ -816,6 +900,10 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", "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: @@ -841,7 +929,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): if "#pragma once" not in content: return ( @@ -1120,7 +1216,75 @@ def lint_no_std_bind(fname, match): ) -LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL) +@lint_re_check( + r"[^\w]std\s*::\s*nothrow\b" + CPP_RE_EOL, + mask=True, + prefilter="nothrow", + include=cpp_include, +) +def lint_no_std_nothrow(fname, match): + return ( + f"{highlight('new (std::nothrow)')} aborts on ESP-IDF when the allocation fails, exceptions are disabled " + f"there, so it never returns nullptr.\n" + f"Please use {highlight('RAMAllocator')} from esphome/core/helpers.h, which does.\n" + f" Before: {highlight('auto *buf = new (std::nothrow) uint8_t[n];')}\n" + f" After: {highlight('auto buf = RAMAllocator().make_unique_array_for_overwrite(n);')}\n" + f"For one object use {highlight('RAMAllocator().make_unique(args...)')}; both return empty on failure.\n" + f"Default flags prefer PSRAM; pass RAMAllocator::PREFER_INTERNAL to keep it where new put it.\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + +LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") +# Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? : +# inside them are never seen. A char literal is exactly one (escaped) char so a digit separator +# like 1'000'000 cannot open one. +CPP_COMMENT_RE = r"//[^\n]*|/\*.*?\*/" +CPP_SKIP_RE = ( + CPP_COMMENT_RE + + r'|R"(?P[^(\s]*)\(.*?\)(?P=raw_delim)"|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\\n]|\\.)\'' +) +LOG_CALL_TOKEN_RE = re.compile(CPP_SKIP_RE + r"|[()]", re.DOTALL) +# The last alternative matches a ? or : followed (after spaces or comments) by an opening quote, +# i.e. a string literal used as a ternary branch. +LOG_TERNARY_LITERAL_RE = re.compile( + CPP_SKIP_RE + r"|[?:](?:\s|" + CPP_COMMENT_RE + r')*(?=")', re.DOTALL +) +# A bare NOLINT; a clang-tidy NOLINT(check-name) is aimed at a different tool. +NOLINT_RE = re.compile(r"\bNOLINT\b(?!\()") + + +def _line_col(content: str, pos: int) -> tuple[int, int]: + """1-based line and column of an offset in content.""" + return content.count("\n", 0, pos) + 1, pos - content.rfind("\n", 0, pos) + + +def _iter_log_calls(content: str) -> Iterator[tuple[int, str | None]]: + """Yield (start, text) for every ESP_LOG*(...) call, text running to the matching close paren. + text is None when no matching paren exists so callers can report the call instead of skipping it.""" + for head in LOG_CALL_START_RE.finditer(content): + depth = 1 + for tok in LOG_CALL_TOKEN_RE.finditer(content, head.end()): + if tok.group(0) == "(": + depth += 1 + elif tok.group(0) == ")": + depth -= 1 + if depth == 0: + yield head.start(), content[head.start() : tok.end()] + break + else: + yield head.start(), None + + +def _unbalanced_log_call_error(content: str, pos: int) -> tuple[int, int, str]: + lineno, col = _line_col(content, pos) + return ( + lineno, + col, + "ESP_LOG call has no matching closing parenthesis, so it cannot be checked.", + ) + + LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])') LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @@ -1128,16 +1292,16 @@ LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @lint_content_check(include=cpp_include) def lint_log_multiline_continuation(fname, content): errs = [] - for log_match in LOG_MULTILINE_RE.finditer(content): - log_text = log_match.group(0) + for log_start, log_text in _iter_log_calls(content): + if log_text is None: + errs.append(_unbalanced_log_call_error(content, log_start)) + continue for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text): # %s may expand to a whitespace prefix at runtime, skip those if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()): continue # Calculate line number from position in full content - abs_pos = log_match.start() + bad_match.start() - lineno = content.count("\n", 0, abs_pos) + 1 - col = abs_pos - content.rfind("\n", 0, abs_pos) + lineno, col = _line_col(content, log_start + bad_match.start()) errs.append( ( lineno, @@ -1155,6 +1319,90 @@ def lint_log_multiline_continuation(fname, content): return errs +def _find_ternary_literals(text: str) -> Iterator[tuple[int, str]]: + """Yield (offset, literal) for every string literal used as a ternary branch.""" + branch = False + for m in LOG_TERNARY_LITERAL_RE.finditer(text): + tok = m.group(0) + # An empty literal is merged with every other string's terminator, so it costs no RAM, + # while a PSTR("") would add its own flash array; leave it alone. + if branch and tok[0] == '"' and tok != '""': + yield m.start(), tok + branch = tok[0] in "?:" + + +# LOG_STR_LITERAL is a no op everywhere except ESP8266, so code that never builds there is skipped +# to avoid churn: platform specific sources and components for ESP32, LibreTiny, RP2 and Zephyr only. +# A component belongs here only if it has no tests/components//test.esp8266-ard.yaml. +LOG_LITERAL_LINT_EXCLUDE = [ + "*_esp32.cpp", + "*_esp32_*.cpp", + "*_esp_idf.cpp", + "*_rmt.cpp", + "*_zephyr.cpp", + "*_bk72xx.cpp", + "*_libretiny.cpp", + "*_pico_w.cpp", + "*_host.cpp", + "esphome/components/esp32*/*", + "esphome/components/bk72xx*/*", + "esphome/components/ln882h*/*", + "esphome/components/ln882x*/*", + "esphome/components/rp2*/*", + "esphome/components/zephyr*/*", + "esphome/components/host/*", + "esphome/components/libretiny*/*", + "esphome/components/bluetooth_proxy/*", + "esphome/components/bluetooth_connection/*", + "esphome/components/ble_client/*", + "esphome/components/bedjet/*", + "esphome/components/anova/*", + "esphome/components/xiaomi_ble/*", + "esphome/components/bthome_mithermometer/*", + "esphome/components/usb_host/*", + "esphome/components/zigbee/*", + "esphome/components/lvgl/*", + # Test fixtures and host only unit tests - not production embedded code + "tests/integration/fixtures/*", + "tests/components/*", +] + + +@lint_content_check(include=cpp_include, exclude=LOG_LITERAL_LINT_EXCLUDE) +def lint_log_no_bare_literal_ternary( + fname: Path, content: str +) -> list[tuple[int, int, str]]: + errs = [] + for log_start, log_text in _iter_log_calls(content): + if log_text is None: + continue # reported by lint_log_multiline_continuation, which sees every file + # A NOLINT anywhere on the lines the call spans silences every branch in it + first_line = content.rfind("\n", 0, log_start) + 1 + last_line = content.find("\n", log_start + len(log_text)) + if NOLINT_RE.search( + content[first_line : last_line if last_line != -1 else None] + ): + continue + for offset, literal in _find_ternary_literals(log_text): + lineno, col = _line_col(content, log_start + offset) + errs.append( + ( + lineno, + col, + ( + "String literal used as a ternary branch in a log call. On ESP8266 the " + "log macro moves the format string to flash, but bare literal arguments " + "stay in RAM. Wrap each branch passed straight to the log call in " + f"{highlight('LOG_STR_LITERAL(...)')}:\n" + f" Before: {highlight(literal)}\n" + f" After: {highlight(f'LOG_STR_LITERAL({literal})')}\n" + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ), + ) + ) + return errs + + @lint_content_find_check( "ESP_LOG", include=["*.h", "*.tcc"], diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 5080a9fec74..678356496e4 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -25,19 +25,14 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" # allow benchmarks to compile on the host platform. STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" -PLATFORMIO_OPTIONS = { - "build_flags": [ - "-Os", # match firmware optimization level (detects inlining regressions) - "-g", # debug symbols for profiling - "-ffunction-sections", # required for dead-code stripping with -Os - "-fdata-sections", # required for dead-code stripping with -Os - "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() - f"-I{STUBS_DIR}", # stub headers for ESP32-only components - ], - # Use deep+ LDF mode to ensure PlatformIO detects the benchmark - # library dependency from nested includes. - "lib_ldf_mode": "deep+", -} +BUILD_FLAGS = [ + "-Os", # match firmware optimization level (detects inlining regressions) + "-g", # debug symbols for profiling + "-ffunction-sections", # required for dead-code stripping with -Os + "-fdata-sections", # required for dead-code stripping with -Os + "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + f"-I{STUBS_DIR}", # stub headers for ESP32-only components +] def run_benchmarks(selected_components: list[str], build_only: bool = False) -> int: @@ -46,7 +41,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> # containing {"lib_path": "/path/to/google_benchmark"}. lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG") - pio_options = PLATFORMIO_OPTIONS + build_flags = BUILD_FLAGS if lib_config_json: lib_config = json.loads(lib_config_json) benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}" @@ -61,10 +56,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> "-DCODSPEED_ANALYSIS", f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"', ] - pio_options = { - **PLATFORMIO_OPTIONS, - "build_flags": PLATFORMIO_OPTIONS["build_flags"] + codspeed_flags, - } + build_flags = BUILD_FLAGS + codspeed_flags else: benchmark_lib = PLATFORMIO_GOOGLE_BENCHMARK_LIB @@ -77,7 +69,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> config_prefix="cppbench", friendly_name="CPP Benchmarks", libraries=benchmark_lib, - platformio_options=pio_options, + build_flags=build_flags, main_entry="main.cpp", label="benchmarks", build_only=build_only, diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index f8bab394149..c5179a1b1e9 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -15,28 +15,21 @@ from helpers import get_all_components, root_path # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" -PLATFORMIO_OPTIONS = { - "build_type": "debug", - "build_unflags": [ - "-Os", # remove size-opt flag - ], - "build_flags": [ - "-Og", # optimize for debug - "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", - "-fno-omit-frame-pointer", - ], - "debug_build_flags": [ # only for debug builds - "-g3", # max debug info - "-ggdb3", - ], -} +BUILD_FLAGS = [ + "-Og", # optimize for debug + "-g3", # max debug info + "-ggdb3", + "-DESPHOME_DEBUG", # enable debug assertions + # Enable the address and undefined behavior sanitizers + "-fsanitize=address", + "-fsanitize=undefined", + "-fno-omit-frame-pointer", +] def run_tests(selected_components: list[str]) -> int: - os.environ["ASAN_OPTIONS"] = "detect_leaks=0" + # allocator_may_return_null: an oversized request must come back empty, not abort the run + os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, @@ -46,7 +39,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - platformio_options=PLATFORMIO_OPTIONS, + build_flags=BUILD_FLAGS, main_entry="main.cpp", label="unit tests", ) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index f5412af21d6..bd127a7a435 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -50,6 +50,7 @@ from __future__ import annotations import argparse from collections import Counter +from collections.abc import Callable from enum import StrEnum from functools import cache import json @@ -110,14 +111,25 @@ INTEGRATION_TESTS_SPLIT_THRESHOLD = 10 INTEGRATION_TESTS_SPLIT_BUCKETS = 5 INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0 -# platformio and aioesphomeapi (requirements.txt), the pytest stack -# (requirements_test.txt) and the fixture every session compiles; a change -# to any runs the full matrix +# aioesphomeapi (requirements.txt), the pytest stack (requirements_test.txt) +# and the native host build backend every test compiles with; a change to +# any runs the full matrix INTEGRATION_TESTS_TRIGGER_FILES = frozenset( { "requirements.txt", "requirements_test.txt", - "tests/integration/fixtures/cache_init.yaml", + "esphome/arduino/library.py", + "esphome/build_gen/build_tool.py", + "esphome/build_gen/host.py", + "esphome/build_helpers/ccache.py", + "esphome/build_helpers/idedata.py", + "esphome/build_helpers/native.py", + "esphome/build_helpers/ninja.py", + "esphome/build_helpers/ninja_gen.py", + "esphome/build_helpers/tools_cache.py", + "esphome/framework_helpers.py", + "esphome/host/toolchain.py", + "esphome/platformio/library.py", } ) @@ -239,7 +251,7 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s - conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc. 4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed - - The dependency pins and the session init fixture affect every test + - The dependency pins and the host build backend affect every test Returns (run_all=False, [test_files...]) when: @@ -520,48 +532,78 @@ ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( } ) +# Shared by every toolchain smoke-test job: the base config and the bus +# packages each generated build includes +_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES = ("tests/test_build_components/",) + # Path prefixes whose changes always trigger the PlatformIO compile test: # anything under esphome/platformio/ (the PlatformIO runner / toolchain that # drives every PlatformIO build). The esp32 platform component is already in # ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal # component-narrowing path. -ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ( + "esphome/platformio/", + *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, +) -# Standalone files that, when changed, trigger the PlatformIO compile test: -# - esphome/build_gen/platformio.py -- the PlatformIO build generator -# - script/test_build_components.py -- the harness the job invokes -# - .github/workflows/ci.yml -- the job's own definition -ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( +# Shared by every toolchain smoke-test job: the harness it invokes and the +# workflow that defines it +_SMOKE_HARNESS_TRIGGER_FILES = frozenset( { - "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) +# Standalone files that, when changed, trigger the PlatformIO compile test +ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | { + # The PlatformIO build generator + "esphome/build_gen/platformio.py", + # Decides which platforms take the pch script in esphome/platformio/, + # and the module that parses its switch and the ccache settings + "esphome/build_helpers/pch.py", + "esphome/build_helpers/ccache.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; cached because the + walk is expensive and every smoke-test job asks for the same list.""" + 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: """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" - for file in files: - 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 + return _path_or_file_trigger( + files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ) + + +# Checks the native ESP-IDF build in CI but does not shape it, so it is kept +# out of ESP_IDF_INFRA_TRIGGER_FILES (hashed into the clang-tidy cache key). +_ESP_IDF_CHECK_TRIGGER_FILES = frozenset({"script/check_idf_py_equivalence.py"}) def _esp_idf_infra_changed(files: list[str]) -> bool: """Whether any changed file is ESP-IDF build/runner infrastructure.""" - for file in files: - 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 + return _path_or_file_trigger( + files, + ESP_IDF_INFRA_TRIGGER_FILES | _ESP_IDF_CHECK_TRIGGER_FILES, + ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES, + ) def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: @@ -599,15 +641,23 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: Returns: 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) - if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): - return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + if core_changed(files) or infra_trigger(files): + return sorted(test_set) - 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)) + return sorted(test_set & _cached_components_closure(tuple(files))) def should_run_esp32_platformio(branch: str | None = None) -> bool: @@ -628,6 +678,85 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool: 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 +ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = ( + "esphome/arduino8266/", + "esphome/arduino/", + "esphome/build_helpers/", + *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, +) +# 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]: + """The smoke set on a native-build change, nothing otherwise. + + Unlike the esp32 PlatformIO job, this one does not narrow to the changed + components: the component matrix already compiles every esp8266 fixture + with this toolchain, so the only gap left is a change to the native build + itself that brings no component along. + """ + files = changed_files(branch) + if core_changed(files) or _esp8266_native_path_or_file_trigger(files): + return sorted(ESP8266_NATIVE_TEST_COMPONENTS) + return [] + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -1183,6 +1312,7 @@ def detect_memory_impact_config( "components": compatible_components, "platform": platform, "use_merged_config": "true", + "needs_arduino8266": platform.startswith("esp8266"), } @@ -1226,6 +1356,8 @@ def main() -> None: run_device_builder = True esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) run_esp32_platformio = True + esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS) + run_esp8266_native = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1237,6 +1369,8 @@ def main() -> None: run_device_builder = should_run_device_builder(args.branch) esp32_platformio_components = esp32_platformio_components_to_test(args.branch) 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( integration_run_all, integration_test_files ) @@ -1406,15 +1540,38 @@ def main() -> None: # Convert batches to CI matrix entries: the component list plus which # native toolchain installs the batch's test platforms need, so the # workflow only restores the matching multi-GB toolchain caches. + # The idf.py check does not depend on the components, so it runs once + # per workflow, in the first batch that compiles an esp32 test (a + # validate-only component is never compiled). + idf_py_check_assigned = False + skip_compile = set(validate_only_components) for batch in batches: platforms: set[str] = set() + compiled_platforms: set[str] = set() for component in batch: - platforms.update(get_component_test_platforms(component)) + # Variants included: the compile stage builds them, so a + # component tested only by test-..yaml + # still needs that platform's toolchain + component_platforms = get_component_test_platforms( + component, base_only=False + ) + platforms.update(component_platforms) + if component not in skip_compile: + compiled_platforms.update(component_platforms) + needs_idf = any(p.startswith("esp32") for p in platforms) + check_idf_py = not idf_py_check_assigned and any( + p.startswith("esp32") for p in compiled_platforms + ) + idf_py_check_assigned |= check_idf_py component_test_batches.append( { "components": " ".join(batch), - "needs_idf": any(p.startswith("esp32") for p in platforms), + "needs_idf": needs_idf, + "check_idf_py": check_idf_py, "needs_nrf": any(p.startswith("nrf52") for p in platforms), + "needs_arduino8266": any( + p.startswith("esp8266") for p in platforms + ), } ) @@ -1432,6 +1589,8 @@ def main() -> None: "device_builder": run_device_builder, "esp32_platformio": run_esp32_platformio, "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_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/script/git-hooks/post-checkout b/script/git-hooks/post-checkout index 8f4085ae6e1..73c1cb0f130 100755 --- a/script/git-hooks/post-checkout +++ b/script/git-hooks/post-checkout @@ -1,20 +1,72 @@ #!/bin/sh # Prepare the dev environment for a new checkout or worktree. # -# Installed into the git hooks directory by script/setup. Deliberately tiny and -# self-contained: it stays valid on branches where script/setup does not exist, -# and simply does nothing there. +# Installed into the git hooks directory by script/setup.py. Deliberately tiny +# and self-contained: it stays valid on branches where the setup script does not +# exist, and simply does nothing there. # $3 is 1 for a branch checkout, 0 for a file checkout. [ "$3" = "1" ] || exit 0 top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -# This also runs on ordinary branch switches, where there is nothing to do. +# This also runs on ordinary branch switches, where there is nothing to do. Both +# layouts are checked because git for Windows runs hooks under its own bundled +# shell, where the environment lives in venv/Scripts rather than venv/bin. [ -x "$top/venv/bin/python" ] && exit 0 -[ -x "$top/script/setup" ] || exit 0 +[ -f "$top/venv/Scripts/python.exe" ] && exit 0 + +# Branches from before the setup script moved to Python carry only the shell +# entry point, so whichever one the checked out branch has is used. +py= +if [ -f "$top/script/setup.py" ]; then + # The interpreter goes by different names across platforms, and on Windows + # "python3" is often a stub that opens the app store instead of running + # anything, so each candidate is tried before it is used. Doing nothing is the + # right outcome when none of them work. + for candidate in "python3" "python" "py -3"; do + # Unquoted on purpose: the launcher candidate is a command plus a flag. + if $candidate -c "" >/dev/null 2>&1; then + py=$candidate + break + fi + done + [ -n "$py" ] || exit 0 +elif ! [ -x "$top/script/setup" ]; then + exit 0 +fi + +# Every worktree shares the hooks directory of the checkout it was created +# from, and the setup script run below is the one from whichever branch was just +# checked out. Older branches install their own pre-commit hook without checking +# for a worktree: that moves the shared hook aside as pre-commit.legacy and +# replaces it with one tied to this worktree's virtual environment, so commits +# break in every checkout. To rule that out, the hooks directory is copied +# before the setup script runs and put back exactly as it was afterwards, +# including removing any file the setup script added. +hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0 +snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0 +cp -p "$hooks"/* "$snap"/ 2>/dev/null # Clear VIRTUAL_ENV so a checkout made from a shell with an environment already # activated still gets its own, rather than having the active one repointed at # this working tree. -exec env -u VIRTUAL_ENV "$top/script/setup" +unset VIRTUAL_ENV +if [ -n "$py" ]; then + # Unquoted on purpose, as above. + $py "$top/script/setup.py" +else + "$top/script/setup" +fi +status=$? + +for f in "$hooks"/*; do + [ -e "$snap/${f##*/}" ] || rm -f "$f" +done +# Files are moved rather than copied so a hook that is still running, such as +# this one, is swapped out atomically instead of being rewritten in place. +for f in "$snap"/*; do + cmp -s "$f" "$hooks/${f##*/}" 2>/dev/null || mv -f "$f" "$hooks/${f##*/}" +done +rm -rf "$snap" +exit $status diff --git a/script/helpers.py b/script/helpers.py index bf22e158086..fb2c964bdd9 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -429,11 +429,9 @@ def build_all_include(header_files: list[str] | None = None) -> None: if line ] - from esphome.writer import ENTITY_TYPES_H_TARGET + from esphome.writer import ESPHOME_H_EXCLUDE - # X-macro files are included multiple times with different macro definitions - # and must not be included bare in the all-include header - exclude = {ENTITY_TYPES_H_TARGET} + exclude = {path.as_posix() for path in ESPHOME_H_EXCLUDE} headers = [f'#include "{h}"' for h in header_files if h not in exclude] headers.sort() headers.append("") @@ -1104,6 +1102,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]: _TEST_FUNC_RE = re.compile(r"async def (test_\w+)") +# Any usage form (decorator, pytestmark assignment or list element); only +# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit +_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml") +_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)") @cache @@ -1123,6 +1125,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]: for func in _TEST_FUNC_RE.findall(content): base_name = func.replace("test_", "").partition("[")[0] result.setdefault(base_name, set()).add(rel_path) + # Shared fixtures are named by marker, not by a test function; each + # decorator must carry a string literal or its fixture would silently + # map to no tests + for use in _SHARED_YAML_USE_RE.finditer(content): + arg = _SHARED_YAML_ARG_RE.match(content, use.end()) + if arg is None: + line = content.count("\n", 0, use.start()) + 1 + raise ValueError( + f"{rel_path}:{line}: shared_yaml marker must take a " + "single-line string literal so CI test selection can map " + "its fixture" + ) + result.setdefault(arg.group(1), set()).add(rel_path) return {k: frozenset(v) for k, v in result.items()} diff --git a/script/platformio_install_deps.py b/script/platformio_install_deps.py deleted file mode 100755 index 1c4fb28b300..00000000000 --- a/script/platformio_install_deps.py +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env python3 -# This script is used to preinstall -# all platformio libraries in the global storage - -import argparse -from concurrent.futures import ThreadPoolExecutor -import configparser -from contextlib import suppress -import os -from pathlib import Path -import queue -import subprocess -import threading -import traceback - -# esphome is not installed at this docker layer; pio's fs.rmtree is the -# same chmod-on-readonly shape its own installer uses -try: - from platformio import fs - from platformio.cache import ContentCache - from platformio.package.manager.base import BasePackageManager - from platformio.package.manager.library import LibraryPackageManager - from platformio.package.manager.tool import ToolPackageManager - from platformio.package.meta import PackageCompatibility - - PARALLEL_AVAILABLE = True -except ImportError as err: # pragma: no cover - # A moved pio module must degrade to the serial pass, not kill the - # image build; the tripwire test makes the drift loud in CI - PARALLEL_AVAILABLE = False - IMPORT_ERROR = repr(err) - -# Network-bound downloads release the GIL, so the pool oversubscribes -# the cores. This bypasses pio's 500ms registry throttle and races its -# self-unlinking cache LockFiles; both are cache-only and self-healing. -MAX_WORKERS = 16 - - -class CleanupError(RuntimeError): - """A torn destination could not be removed; the serial pass would - trust it, so the build must fail rather than bake a corrupt image.""" - - -class LockReleaseError(RuntimeError): - """The manager lock could not be released; the serial pass would - block on it, so the build must fail with the cause named.""" - - -def parse_specs(path: str, args: argparse.Namespace) -> tuple[list, list, list]: - """Extract lib/platform/tool specs from every section of a platformio.ini.""" - config = configparser.ConfigParser(inline_comment_prefixes=(";",)) - if not config.read(path): - # ConfigParser silently ignores unreadable files; an empty spec - # list would build an image with no dependencies at all - raise SystemExit(f"Could not read {path}") - libs = [] - tools = [] - platforms = [] - for section in config.sections(): - conf = config[section] - if "lib_deps" in conf and args.libraries: - for lib_dep in conf["lib_deps"].splitlines(): - if not lib_dep: - # Empty line or comment - continue - if lib_dep.startswith("${"): - # Extending from another section - continue - if "@" not in lib_dep: - # No version pinned, this is an internal lib - continue - libs.append(lib_dep) - if "platform" in conf and args.platforms: - platforms.append(conf["platform"]) - if "platform_packages" in conf and args.tools: - for tool in conf["platform_packages"].splitlines(): - if not tool: - # Empty line or comment - continue - if tool.startswith("${"): - # Extending from another section - continue - if tool.find("https://github.com") != -1: - split = tool.find("@") - tool = tool[split + 1 :] - tools.append(tool) - # Exact-string dedupe only: name-level dedupe would change which - # version conflicts the pkg install pass reconciles - return ( - list(dict.fromkeys(libs)), - list(dict.fromkeys(platforms)), - list(dict.fromkeys(tools)), - ) - - -def piopm_matches(package_dir: str, spec) -> list[Path]: - """Dirs whose .piopm metadata names this spec; a positive match beats - guessing the manifest-derived dirname from the registry name.""" - want = (BasePackageManager.ensure_spec(spec).name or "").lower() - matches: list[Path] = [] - if not want: - return matches - try: - entries = list(Path(package_dir).iterdir()) - except FileNotFoundError: - return matches - for d in entries: - if not d.is_dir(): - continue # pio's get_installed skips files and *.pio-link too - try: - meta = fs.load_json(str(d / ".piopm")) - except FileNotFoundError: - continue # no metadata means pio does not trust it either - except (OSError, ValueError): - if d.name.lower() == want: - # A corrupt .piopm under this spec's own name would crash - # pio's whole storage scan; remove it - matches.append(d) - continue - mspec = meta.get("spec") or {} - if (mspec.get("name") or meta.get("name") or "").lower() == want: - matches.append(d) - return matches - - -def remove_dir(spec, dest: Path) -> None: - # fs.rmtree never raises (errors go to a printing onexc handler); - # only the destination's absence proves the cleanup worked - fs.rmtree(str(dest)) - if dest.exists(): - # Failing the build beats baking a corrupt image - raise CleanupError( - f"could not remove the failed pre-install of {spec} at {dest}" - ) - print(f"Removed torn destination {dest}", flush=True) - - -def cleanup_or_die(mgr, spec) -> None: - """Cleanup that did not demonstrably succeed must fail the build.""" - try: - clean_torn(mgr, spec) - except CleanupError: - raise - except Exception as err: # noqa: BLE001 - raise CleanupError(f"cleanup failed for {spec}: {err!r}") from err - - -def clean_torn(mgr, spec) -> None: - """Remove a torn destination so the serial pass cannot trust it.""" - pkg = None - with suppress(Exception): - # get_package memoizes a pre-install snapshot; reset to see the - # torn dir. It also recognizes manifest-only legacy dirs pio's - # storage scan would trust, which the .piopm fallback cannot see. - mgr.memcache_reset() - pkg = mgr.get_package(spec) - if pkg is not None: - remove_dir(spec, Path(pkg.path)) - elif dests := piopm_matches(mgr.package_dir, spec): - # A .piopm naming this spec is the exact shape the serial pass - # trusts; a dir without one is overwritten by pio's own install - for dest in dests: - remove_dir(spec, dest) - else: - print(f"No resolvable destination to clean for {spec}", flush=True) - - -def spec_key(spec) -> str | None: - """The destination identity of a spec: PlatformIO installs by package - name, so two specs sharing a name share a directory. ``None`` means - the name could not be derived; such a spec must stay out of the wave - (a raw-string key would break the one-per-destination guarantee).""" - name = BasePackageManager.ensure_spec(spec).name - return name.lower() if name else None - - -def dependency_specs(manager, specs: list) -> list: - """``(spec, compatibility)`` registry dependencies of installed - packages, from local manifest reads. Name-only dependencies - (platform-bundled libs like SPI) stay with the ``pkg install`` pass; - the compatibility qualifiers mirror pio's install_dependency, so a - qualified dep resolves to the same package the serial pass picks.""" - return [ - (manager.dependency_to_spec(dep), PackageCompatibility.from_dependency(dep)) - for spec in specs - if (pkg := manager.get_package(spec)) is not None - for dep in manager.get_pkg_dependencies(pkg) or [] - if dep.get("owner") or dep.get("version") - ] - - -def parallel_install(manager_cls, specs: list, prior_names: set | None = None) -> None: - """Best-effort parallel top-level install. - - PlatformIO's own installer downloads and unpacks one package at a time - on one core. Dependencies are skipped (two packages sharing one must - not extract into the same directory from two threads) and failures are - only reported: the stock ``pkg install`` pass afterwards installs - whatever is missing and is the authority on the final state. - """ - if not specs: - return - manager = manager_cls(None) - # One spec per destination: two threads must not extract into the - # same directory. Second versions of a name and URL specs (their dir - # comes from the archive manifest) stay with the pkg install pass. - seen_names: set = prior_names if prior_names is not None else set() - # Wave-1 items are strings; dependency waves carry (spec, compatibility) - pairs = [item if isinstance(item, tuple) else (item, None) for item in specs] - unique = {} - for spec, compat in pairs: - # Normalize once: a dependency's URL version surfaces as spec.uri - parsed = BasePackageManager.ensure_spec(spec) - if parsed.uri: - continue - if (key := spec_key(parsed)) is None: - # No name, no destination identity; leave it to the serial pass - print(f"Skipping unresolvable spec {spec!r} in the wave", flush=True) - continue - unique.setdefault(key, (spec, compat)) # first-wins, like pio's walk - pending = [ - (spec, compat) - for spec, compat in unique.values() - if not manager.get_package(spec) - ] - if not pending: - # Nothing to install, but a warm store's dependencies must still - # feed the next wave (a transitive dep may be missing) - _next_wave(manager_cls, manager, unique, seen_names) - return - workers = min(len(pending), MAX_WORKERS) - # One manager per worker (_install mutates instance state); built - # serially because construction rewires the shared manager logger - managers: queue.SimpleQueue = queue.SimpleQueue() - for _ in range(workers): - managers.put(manager_cls(None)) - local = threading.local() - - def install_one(item) -> bool: - spec, compat = item - if (mgr := getattr(local, "mgr", None)) is None: - mgr = local.mgr = managers.get_nowait() - try: - mgr._install( # noqa: SLF001 - spec, skip_dependencies=True, compatibility=compat - ) - return True - except Exception as err: # noqa: BLE001 - print(f"Pre-install of {spec} failed ({err!r})", flush=True) - cleanup_or_die(mgr, spec) - return False - except BaseException: - # A worker SystemExit (main() guards against it) must not skip - # the cleanup and leave a torn dir the serial pass trusts - cleanup_or_die(mgr, spec) - raise - - print(f"Preinstalling {len(pending)} package(s) with {workers} workers", flush=True) - # The serial getter calls create pio's lazy dirs (made without - # exist_ok) before cold-cache workers can race the creation - manager.get_download_dir() - manager.get_tmp_dir() - ContentCache("http") - cwd = Path.cwd() - manager.lock() - try: - with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(install_one, item) for item in pending] - # The with-block joined every future; drain them all so a - # concurrent CleanupError is never dropped - errors = [err for f in futures if (err := f.exception()) is not None] - for err in errors: - # Every failure is on the record; the raised one is a summary - print(f"Wave failure: {err!r}", flush=True) - if errors: - raise next((e for e in errors if isinstance(e, CleanupError)), errors[0]) - results = [f.result() for f in futures] - finally: - try: - manager.unlock() - except Exception as unlock_err: # noqa: BLE001 - # A held flock would hang the serial pass in another process; - # failing loudly beats an unexplained stuck docker build. Any - # in-flight error stays attached as the context. - raise LockReleaseError( - f"could not release the manager lock: {unlock_err!r}" - ) from unlock_err - # Worker postinstall scripts chdir process-wide (pio's fs.cd); - # restore between waves. The serial pass pins its own cwd. - with suppress(OSError): - os.chdir(cwd) - if failures := len(results) - sum(results): - # The stock pass retries CLI specs and re-walks installed - # packages' dependencies, so failed deps retry too - print( - f"Pre-install failed for {failures} of {len(results)} package(s); " - "pkg install retries them serially", - flush=True, - ) - - # Waves skip dependencies (a shared one must not extract from two - # threads); the installed manifests feed the next wave - _next_wave(manager_cls, manager, unique, seen_names) - - -def _next_wave(manager_cls, manager, unique: dict, seen_names: set) -> None: - """Queue the dependency wave for every requested spec, installed or - freshly waved; a warm store can still be missing a transitive dep. - Terminates without a cap: each wave admits only never-seen names.""" - seen_names.update(unique) - # The pre-wave get_package calls memoized an empty storage snapshot - manager.memcache_reset() - next_specs = [ - item - for item in dependency_specs(manager, [spec for spec, _ in unique.values()]) - if spec_key(item[0]) not in seen_names - ] - if next_specs: - parallel_install(manager_cls, next_specs, seen_names) - - -def build_cli_args(libs: list, platforms: list, tools: list) -> list: - return [ - arg - for flag, specs in (("-l", libs), ("-p", platforms), ("-t", tools)) - for spec in specs - for arg in (flag, spec) - ] - - -def main() -> None: - parser = argparse.ArgumentParser(description="") - parser.add_argument("file", help="Path to platformio.ini", nargs=1) - parser.add_argument( - "-l", "--libraries", help="Install libraries", action="store_true" - ) - parser.add_argument( - "-p", "--platforms", help="Install platforms", action="store_true" - ) - parser.add_argument("-t", "--tools", help="Install tools", action="store_true") - args = parser.parse_args() - start_cwd = Path.cwd() - libs, platforms, tools = parse_specs(args.file[0], args) - - # Platforms stay serial: PlatformPackageManager.install runs an - # on_installed hook the private _install path would skip - if PARALLEL_AVAILABLE: - wave_groups = [(ToolPackageManager, tools), (LibraryPackageManager, libs)] - else: # pragma: no cover - wave_groups = [] - print( - f"PlatformIO layout changed ({IMPORT_ERROR}); serial install only", - flush=True, - ) - for manager_cls, specs in wave_groups: - try: - parallel_install(manager_cls, specs) - except (CleanupError, LockReleaseError, KeyboardInterrupt): - # A torn package or a held lock must fail the build - raise - except BaseException: # noqa: BLE001 - # BaseException: a worker postinstall's SystemExit must not - # skip the authoritative serial pass (partial deps, exit 0) - print("Parallel preinstall failed, falling back to serial", flush=True) - traceback.print_exc() - - # Postinstall scripts chdir process-wide (pio's fs.cd captures its - # restore path at construction); pin the authoritative pass's cwd - subprocess.check_call( - ["platformio", "pkg", "install", "-g", *build_cli_args(libs, platforms, tools)], - close_fds=False, - cwd=start_cwd, - ) - - -if __name__ == "__main__": - main() diff --git a/script/setup b/script/setup index b96af6e8f34..91bcb881541 100755 --- a/script/setup +++ b/script/setup @@ -1,71 +1,7 @@ #!/usr/bin/env bash -# Set up ESPHome dev environment +# Set up ESPHome dev environment. +# +# The work is done by setup.py, which script/setup.bat also runs, so the Unix +# and Windows entry points share one implementation. -set -e - -cd "$(dirname "$0")/.." -if [ -n "$VIRTUAL_ENV" ]; then - # A virtual environment is already active (e.g. the devcontainer's pre-provisioned - # esphome-venv). Install into it rather than creating a ./venv in the workspace. - venv_state=active -elif [ -x venv/bin/python ]; then - # Reuse the environment from an earlier run, so this script can be run again - # at any time to pick up dependency changes. - venv_state=reused - source venv/bin/activate -else - venv_state=created - # --clear replaces a partial environment left behind by an interrupted run. - if [ -x "$(command -v uv)" ]; then - uv venv --clear --seed venv - else - python3 -m venv --clear venv - fi - source venv/bin/activate -fi - -if ! [ -x "$(command -v uv)" ]; then - python3 -m pip install uv -fi - -uv pip install setuptools wheel -uv pip install -e ".[dev,test]" --config-settings editable_mode=compat - -# A worktree shares one git hooks directory with the main checkout it was -# created from, so hooks are installed from the main checkout only. Installing -# from a worktree would point the shared hook at that worktree's virtual -# environment, breaking it for everyone once the worktree is removed. -git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" -common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" -if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then - # --overwrite replaces any hook already in place. Without it, prek finds a - # previously installed pre-commit hook, moves it aside to - # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would - # run both tools. - prek install --overwrite - - # Prepares the virtual environment for new checkouts and worktrees. Installed - # once here, it covers every worktree created from this checkout. - if [ -d "$common_dir/hooks" ]; then - cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout" - chmod +x "$common_dir/hooks/post-checkout" - fi -fi - -mkdir -p .temp - -echo -echo -case "$venv_state" in - created) - echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." - ;; - reused) - echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it." - ;; - active) - echo "Dependencies installed into the active virtual environment:" - echo " $VIRTUAL_ENV" - echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." - ;; -esac +exec python3 "$(dirname "$0")/setup.py" "$@" diff --git a/script/setup.bat b/script/setup.bat index 809d05ae933..405121b1390 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -1,28 +1 @@ -@echo off - -if defined VIRTUAL_ENV goto :install - -echo Starting the Virtual Environment -python -m venv venv -call venv/Scripts/activate -echo Running the Virtual Environment - -:install - -echo Installing required packages... - -python.exe -m pip install --upgrade pip - -pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.txt -pip3 install setuptools wheel -pip3 install -e ".[dev,test]" --config-settings editable_mode=compat - -rem --overwrite replaces any hook already in place. Without it, prek finds a -rem previously installed pre-commit hook, moves it aside to -rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would -rem run both tools. -prek install --overwrite - -echo . -echo . -echo Virtual environment created. Run 'venv/Scripts/activate' to use it. +@python "%~dp0setup.py" %* diff --git a/script/setup.py b/script/setup.py new file mode 100755 index 00000000000..62129b8c050 --- /dev/null +++ b/script/setup.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Set up the ESPHome development environment. + +Shared implementation behind script/setup and script/setup.bat, so the Unix and +Windows entry points cannot drift apart. Uses only the standard library: it runs +before any dependency has been installed. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig + +MIN_PYTHON = (3, 12) + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_VENV = ROOT / "venv" +POST_CHECKOUT_HOOK = ROOT / "script" / "git-hooks" / "post-checkout" + +# State of the environment the dependencies end up in, used for the closing +# message. +VENV_ACTIVE = "active" +VENV_REUSED = "reused" +VENV_CREATED = "created" + + +def bin_dir(venv: Path) -> Path: + """Return the directory holding a virtual environment's executables. + + The "venv" scheme resolves to bin on Unix and Scripts on Windows, so the + layout does not have to be hardcoded here. + """ + base = str(venv) + return Path( + sysconfig.get_path("scripts", "venv", vars={"base": base, "platbase": base}) + ) + + +def venv_python(venv: Path) -> Path: + """Return the path to a virtual environment's interpreter.""" + name = "python.exe" if os.name == "nt" else "python" + return bin_dir(venv) / name + + +def run(command: list[str], env: dict[str, str] | None = None) -> None: + """Run a command, aborting the whole script if it fails.""" + print(f"+ {' '.join(command)}", flush=True) + result = subprocess.run(command, cwd=ROOT, env=env, check=False) + if result.returncode != 0: + # Some tools fail without printing anything, so name the step that broke. + print( + f"Failed with exit code {result.returncode}: {command[0]}", file=sys.stderr + ) + raise SystemExit(result.returncode) + + +def git_output(*args: str) -> str: + """Return the trimmed output of a git command, or "" if it cannot be run.""" + try: + result = subprocess.run( + ["git", *args], cwd=ROOT, capture_output=True, text=True, check=False + ) + except OSError: + # Git is not required to install the dependencies, only to install hooks. + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def create_venv(venv: Path) -> None: + """Create a virtual environment, replacing anything already at the path.""" + # --clear replaces a partial environment left behind by an interrupted run. + if (uv := shutil.which("uv")) is not None: + run([uv, "venv", "--clear", "--seed", str(venv)]) + else: + run([sys.executable, "-m", "venv", "--clear", str(venv)]) + + +def venv_environment(venv: Path) -> dict[str, str]: + """Return the environment child processes need to target a virtual env. + + Equivalent to sourcing the environment's activate script: tools such as uv + and prek pick the environment up from VIRTUAL_ENV and PATH. + """ + env = dict(os.environ) + env["VIRTUAL_ENV"] = str(venv) + env.pop("PYTHONHOME", None) + path = str(bin_dir(venv)) + # An empty entry would be appended if PATH is unset, and on Unix that means + # the working directory is searched for executables. + if existing := env.get("PATH"): + path = os.pathsep.join([path, existing]) + env["PATH"] = path + return env + + +def find_uv(venv: Path, env: dict[str, str]) -> str: + """Return the path to uv, installing it into the environment if needed.""" + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + run([str(venv_python(venv)), "-m", "pip", "install", "uv"], env=env) + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + raise SystemExit("uv could not be installed, aborting.") + + +def install_dependencies(venv: Path, env: dict[str, str]) -> None: + """Install ESPHome and its development dependencies into the environment.""" + uv = find_uv(venv, env) + run([uv, "pip", "install", "setuptools", "wheel"], env=env) + # The dev and test extras pull in requirements_dev.txt and + # requirements_test.txt, and the package itself pulls in requirements.txt, + # so this single install covers every requirements file. + run( + [ + uv, + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + env=env, + ) + + +def install_git_hooks(env: dict[str, str]) -> None: + """Install the git hooks, but only when run from the main checkout. + + A worktree shares one git hooks directory with the main checkout it was + created from. Installing from a worktree would point the shared hook at that + worktree's virtual environment, breaking it for everyone once the worktree is + removed. + """ + git_dir = git_output("rev-parse", "--absolute-git-dir") + common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir") + if not git_dir or not common_dir or Path(git_dir) != Path(common_dir): + return + + prek = shutil.which("prek", path=env["PATH"]) + if prek is None: + raise SystemExit("prek was not installed, aborting.") + # --overwrite replaces any hook already in place. Without it, prek finds a + # previously installed pre-commit hook, moves it aside to + # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would + # run both tools. + run([prek, "install", "--overwrite"], env=env) + + # Prepares the virtual environment for new checkouts and worktrees. Installed + # once here, it covers every worktree created from this checkout. + hooks_dir = Path(common_dir) / "hooks" + if hooks_dir.is_dir(): + installed = hooks_dir / "post-checkout" + shutil.copyfile(POST_CHECKOUT_HOOK, installed) + installed.chmod(0o755) + + +def activate_hint() -> str: + """Return the command that activates the environment this script creates.""" + activate = bin_dir(DEFAULT_VENV).relative_to(ROOT) / "activate" + if os.name == "nt": + return str(activate) + return f"source {activate.as_posix()}" + + +def report(state: str, venv: Path) -> None: + """Print the closing message for the environment that was set up.""" + location = f"./{DEFAULT_VENV.name}" + print() + print() + if state == VENV_ACTIVE: + print("Dependencies installed into the active virtual environment:") + print(f" {venv}") + print( + f"It is already active in this shell, so no '{activate_hint()}' is needed." + ) + elif state == VENV_REUSED: + print( + f"Dependencies updated in the existing {location}. " + f"Run '{activate_hint()}' to use it." + ) + else: + print( + f"Virtual environment created at {location}. " + f"Run '{activate_hint()}' to use it." + ) + + +def main() -> None: + """Set up the development environment.""" + if sys.version_info < MIN_PYTHON: + raise SystemExit( + f"ESPHome needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer, " + f"but this is Python {sys.version.split()[0]}." + ) + + # A virtual environment that is already active (for example the + # devcontainer's pre-provisioned esphome-venv) is installed into rather than + # creating a ./venv in the workspace. + if active := os.environ.get("VIRTUAL_ENV"): + state, venv = VENV_ACTIVE, Path(active) + elif venv_python(DEFAULT_VENV).is_file(): + # Reuse the environment from an earlier run, so this script can be run + # again at any time to pick up dependency changes. + state, venv = VENV_REUSED, DEFAULT_VENV + else: + state, venv = VENV_CREATED, DEFAULT_VENV + create_venv(venv) + + env = venv_environment(venv) + install_dependencies(venv, env) + install_git_hooks(env) + (ROOT / ".temp").mkdir(exist_ok=True) + report(state, venv) + + +if __name__ == "__main__": + main() diff --git a/script/sync_dependency_versions.py b/script/sync_dependency_versions.py new file mode 100755 index 00000000000..a97a58b3b0e --- /dev/null +++ b/script/sync_dependency_versions.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Keep pre-commit hook revs in sync with the requirements files. + +Dependabot only bumps the ``package==version`` pins in ``requirements*.txt``. +Some of those tools are pinned a second time as hook ``rev`` values in +``.pre-commit-config.yaml``. This script treats the requirements files as +the source of truth and rewrites the revs to match, editing the config +through yamlrocks so comments and layout survive. + +Run without arguments to apply the changes in place, or with ``--check`` to +only report drift (exit status 1 when anything is out of sync). +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import sys +from typing import Any + +import yamlrocks + +REPO_ROOT = Path(__file__).resolve().parent.parent +PRECOMMIT_CONFIG = ".pre-commit-config.yaml" + + +class SyncError(Exception): + """A pin could not be located in a requirements file or the config.""" + + +@dataclass(frozen=True) +class SyncTarget: + """A requirements pin and the pre-commit repo whose rev mirrors it.""" + + package: str + requirements_file: str + repo: str + + +SYNC_TARGETS: tuple[SyncTarget, ...] = ( + SyncTarget( + "ruff", "requirements_test.txt", "https://github.com/astral-sh/ruff-pre-commit" + ), + SyncTarget("flake8", "requirements_test.txt", "https://github.com/PyCQA/flake8"), + SyncTarget( + "pyupgrade", "requirements_test.txt", "https://github.com/asottile/pyupgrade" + ), + SyncTarget( + "clang-format", + "requirements_dev.txt", + "https://github.com/pre-commit/mirrors-clang-format", + ), + SyncTarget( + "yamllint", + "requirements_dev.txt", + "https://github.com/adrienverge/yamllint.git", + ), +) + + +def read_requirement_version(requirements: str, package: str) -> str | None: + """Return the ``==`` pin for ``package`` or None when it is not pinned.""" + pattern = re.compile( + rf"^{re.escape(package)}==(?P[^\s#]+)", + re.MULTILINE | re.IGNORECASE, + ) + match = pattern.search(requirements) + return match.group("version") if match else None + + +def find_repo_entry(doc: Any, repo: str) -> Any: + """Return the single ``- repo:`` block for ``repo`` in a pre-commit doc.""" + try: + entries = [entry for entry in doc["repos"] if entry["repo"] == repo] + except KeyError as err: + raise SyncError(f"malformed pre-commit config, missing key {err}") from None + if len(entries) != 1: + raise SyncError( + f"expected exactly one block for repo {repo}, found {len(entries)}" + ) + return entries[0] + + +def current_rev(entry: Any, repo: str) -> tuple[str, str]: + """Split the block's rev into its tag prefix (``v`` or empty) and version.""" + if "rev" not in entry: + raise SyncError(f"repo {repo} has no rev") + rev = entry["rev"] + if not isinstance(rev, str): + # A rev such as ``1.0`` parses as a number and cannot be compared or + # rewritten safely; quote it in the config instead. + raise SyncError(f"rev of repo {repo} is not a string: {rev!r}") + prefix = "v" if rev.startswith("v") else "" + return prefix, rev.removeprefix("v") + + +def sync(root: Path, *, write: bool) -> list[str]: + """Bring every hook rev in line with its requirements pin. + + Returns one description per rev that was (or, when ``write`` is False, + would be) changed. Raises SyncError when a pin cannot be found, which + means SYNC_TARGETS has gone stale and needs updating by hand. + """ + config_path = root / PRECOMMIT_CONFIG + doc = yamlrocks.loads(config_path.read_bytes(), option=yamlrocks.OPT_ROUND_TRIP) + requirements: dict[str, str] = {} + changes: list[str] = [] + for target in SYNC_TARGETS: + if target.requirements_file not in requirements: + requirements[target.requirements_file] = ( + root / target.requirements_file + ).read_text() + version = read_requirement_version( + requirements[target.requirements_file], target.package + ) + if version is None: + raise SyncError( + f"{target.requirements_file}: no '{target.package}==' pin found" + ) + + entry = find_repo_entry(doc, target.repo) + prefix, current = current_rev(entry, target.repo) + if current == version: + continue + changes.append(f"{target.package}: {current} -> {version}") + entry["rev"] = f"{prefix}{version}" + + if changes and write: + config_path.write_bytes(doc.to_yaml()) + return changes + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", + action="store_true", + help="report drift without modifying any file; exit 1 if out of sync", + ) + parser.add_argument( + "--root", + type=Path, + default=REPO_ROOT, + help="repository checkout to operate on (default: this checkout)", + ) + args = parser.parse_args(argv) + + try: + changes = sync(args.root, write=not args.check) + except SyncError as err: + print(f"error: {err}", file=sys.stderr) + return 1 + + for change in changes: + print(change) + if args.check and changes: + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/script/test_build_components.py b/script/test_build_components.py index ddd8a6a67d7..d3dfd360763 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -1027,6 +1027,7 @@ def test_components( isolated_components: set[str] | None = None, base_only: bool = False, toolchain: str | None = None, + fail_on_no_tests: bool = False, ) -> int: """Test components with optional intelligent grouping. @@ -1061,20 +1062,32 @@ def test_components( # toolchain build. include_validate = esphome_command != "compile" - # Find all component tests + # A blank pattern list would slide into the reference-baseline + # fallback and exit green while building nothing + if fail_on_no_tests and not any(component_patterns): + print("No components requested (blank component list)") + return 1 + + # Find all component tests; remember which components each pattern + # (wildcards included) matched, for the deferred no-tests accounting all_tests = {} + pattern_components: dict[str, set[str]] = {} for pattern in component_patterns: # Skip empty patterns (happens when components list is empty string) if not pattern: continue - all_tests.update( - find_component_tests( - tests_dir, pattern, base_only, include_validate=include_validate - ) + found = find_component_tests( + tests_dir, pattern, base_only, include_validate=include_validate ) + pattern_components[pattern] = set(found) + all_tests.update(found) + + if fail_on_no_tests and not all_tests: + # Nothing matched: fail before the synthetic baseline spends a + # compile reporting success on nothing + 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: print(f"No components found matching: {component_patterns}") print( @@ -1178,6 +1191,23 @@ def test_components( toolchain=toolchain, ) + silent: list[str] = [] + if fail_on_no_tests: + # A green run that built nothing for a requested pattern must not + # pass CI. Per pattern so one silent pattern cannot hide behind + # the others; opt-in because some legs legitimately match nothing; + # deferred past the summary so 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 passed_results = [r for r in test_results if r.success] failed_results = [r for r in test_results if not r.success] @@ -1209,7 +1239,7 @@ def test_components( if os.environ.get("GITHUB_STEP_SUMMARY"): write_github_summary(test_results, toolchain=toolchain) - if failed_results: + if failed_results or silent: return 1 return 0 @@ -1264,6 +1294,12 @@ def main() -> int: "--toolchain", 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() @@ -1282,6 +1318,7 @@ def main() -> int: continue_on_fail=args.continue_on_fail, enable_grouping=not args.no_grouping, isolated_components=isolated_components, + fail_on_no_tests=args.fail_on_no_tests, base_only=args.base_only, toolchain=args.toolchain, ) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 2bd702f48e5..f4fe331df45 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,6 +17,8 @@ CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n +CONFIG_FREERTOS_USE_TICKLESS_IDLE=y +CONFIG_PM_ENABLE=y # esp32_ble CONFIG_BT_ENABLED=y diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index 05bbcc73dd8..e45966999bd 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -249,7 +249,7 @@ static APIBuffer build_infrared_rf_transmit_wire() { std::memcpy(bytes + len, packed, packed_len); len += packed_len; // field 6: modulation = 1 (non-zero so it's actually emitted and exercises - // decode_varint for this field, matching the documented layout above). + // decode_field for this field, matching the documented layout above). put_byte(0x30); put_varint(1); diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py index 233a3c246e5..bffaf0febf1 100644 --- a/tests/benchmarks/components/light/__init__.py +++ b/tests/benchmarks/components/light/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.light import generate_gamma_table +from esphome.components.light import gamma_table_initializer from tests.testing_helpers import ComponentManifestOverride @@ -12,15 +12,13 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) cg.add_define("USE_LIGHT_GAMMA_LUT") - # Use the light component's own generate_gamma_table() so the + # Use the light component's own gamma_table_initializer() so the # benchmark stays in sync with any formula changes. - forward = generate_gamma_table(2.8) - values = ", ".join(f"0x{int(v):04X}" for v in forward) - # Use extern-visible (non-static) array so the benchmark .cpp - # can reference it via extern declaration. + # Extern-visible (non-static) so the benchmark .cpp can reference it. cg.add_global( cg.RawStatement( - f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + "extern const esphome::light::GammaTable bench_gamma_2_8 PROGMEM = " + f"{gamma_table_initializer(2.8)};" ) ) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index c1ef0c425e0..e102d323714 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -4,8 +4,8 @@ #include "esphome/components/light/light_state.h" // Gamma 2.8 forward LUT generated by the light component's Python codegen -// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) -extern const uint16_t bench_gamma_2_8_fwd[256]; +// (see tests/benchmarks/components/light/__init__.py which calls gamma_table_initializer()) +extern const esphome::light::GammaTable bench_gamma_2_8; namespace esphome::benchmarks { @@ -37,9 +37,7 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { output.traits_.set_max_mireds(500.0f); light.configure("test_light"); light.set_default_transition_length(0); - light.set_gamma_correct(2.8f); - light.set_gamma_table(bench_gamma_2_8_fwd); - light.set_restore_mode(light::LIGHT_ALWAYS_OFF); + light.set_gamma_table(&bench_gamma_2_8); } // --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index 6fc20f33508..7da6fff017b 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -40,6 +40,9 @@ class SerialProxy { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; + } SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } diff --git a/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml b/tests/component_tests/api/config/bare.yaml similarity index 51% rename from tests/unit_tests/fixtures/yaml_util/missing_comp.yaml rename to tests/component_tests/api/config/bare.yaml index d065901ed97..be5c73f18bc 100644 --- a/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml +++ b/tests/component_tests/api/config/bare.yaml @@ -1,3 +1,4 @@ +--- esphome: name: test @@ -5,8 +6,7 @@ esp32: board: esp32dev wifi: - ap: ~ + ssid: test + password: testtest -image: - - id: its_a_bug - file: "mdi:bug" +api: diff --git a/tests/component_tests/api/config/custom.yaml b/tests/component_tests/api/config/custom.yaml new file mode 100644 index 00000000000..cdf4038d5d5 --- /dev/null +++ b/tests/component_tests/api/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6054 + reboot_timeout: 0s + batch_delay: 0ms diff --git a/tests/component_tests/api/config/defaults.yaml b/tests/component_tests/api/config/defaults.yaml new file mode 100644 index 00000000000..b20fd9b884c --- /dev/null +++ b/tests/component_tests/api/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6053 + reboot_timeout: 15min + batch_delay: 100ms diff --git a/tests/component_tests/api/test_default_setters.py b/tests/component_tests/api/test_default_setters.py new file mode 100644 index 00000000000..32d35cacb7c --- /dev/null +++ b/tests/component_tests/api/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that the api component only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "api_apiserver_id->set_port(" not in main_cpp + assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp + assert "api_apiserver_id->set_batch_delay(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "api_apiserver_id->set_port(6054);" in main_cpp + assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp + assert "api_apiserver_id->set_batch_delay(0);" in main_cpp diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 00000000000..87f599c66e8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 00000000000..6973e6e26f8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index 777ae76b4f0..3f5280d9659 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -39,12 +39,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp diff --git a/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py b/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py new file mode 100644 index 00000000000..84d8b0677f7 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py @@ -0,0 +1,13 @@ +"""The codegen hook external filtering components use to turn on the filter slot.""" + +from esphome.components import bluetooth_proxy +from esphome.core import CORE + + +def test_enable_advertisement_filter_emits_define() -> None: + """External components call this rather than emitting the define.""" + bluetooth_proxy.enable_advertisement_filter() + + assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in { + define.name for define in CORE.defines + } diff --git a/tests/component_tests/cdc_acm_uart/__init__.py b/tests/component_tests/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/cdc_acm_uart/test_init.py b/tests/component_tests/cdc_acm_uart/test_init.py new file mode 100644 index 00000000000..7bbf163fc32 --- /dev/null +++ b/tests/component_tests/cdc_acm_uart/test_init.py @@ -0,0 +1,154 @@ +"""Tests for the bridge cdc_acm_uart platform's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.cdc_acm_uart import bridge +from esphome.components.cdc_acm_uart.bridge import CONF_USB_CDC_ACM_ID +from esphome.config import Config +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +_final_validate = bridge._final_validate + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable, **kwargs) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + **kwargs, + ) + + +def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config: + """A full config declaring uart_0 and uart_1 (plus any extra entries), as the ID + pass leaves it, so the debug check can resolve a uart_id to its declaration.""" + uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}] + full = Config() + full["uart"] = uarts + for index, uart_conf in enumerate(uarts): + full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID])) + full.update(domains) + return full + + +def _bridge_config(uart_id: str, cdc_id: str) -> dict: + return {CONF_UART_ID: ID(uart_id), CONF_USB_CDC_ACM_ID: ID(cdc_id)} + + +def test_accepts_distinct_uart_and_cdc_interfaces( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + _final_validate(_bridge_config("uart_1", "cdc_acm_2")) + + +def test_rejects_two_bridges_sharing_a_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + with pytest.raises(cv.Invalid, match="already bridged"): + _final_validate(_bridge_config("uart_0", "cdc_acm_2")) + + +def test_rejects_two_bridges_sharing_a_cdc_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + with pytest.raises(cv.Invalid, match="already bridged"): + _final_validate(_bridge_config("uart_1", "cdc_acm_1")) + + +def test_rejects_uart_shared_with_another_component( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_0")}], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_cdc_interface_shared_with_another_component( + set_core_config: SetCoreConfigCallable, +) -> None: + # The CDC instance is itself a uart::UARTComponent, so other components can bind + # it as a plain UART via uart_id -- that must be rejected just like UART sharing. + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("cdc_acm_1")}], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_uart_referenced_from_nested_config( + set_core_config: SetCoreConfigCallable, +) -> None: + # References can sit arbitrarily deep, e.g. inside an automation's action list. + _set_esp32_s3( + set_core_config, + full_config=_full_config( + binary_sensor=[ + { + "platform": "gpio", + "on_press": [{"then": [{CONF_UART_ID: ID("uart_0")}]}], + } + ], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_ignores_other_components_on_other_uarts( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_1")}], + # The bridge domain itself is skipped: this bridge's own entry (and any + # bridge-vs-bridge sharing, which the seen-set already rejects) must not + # trip the exclusivity scan. + bridge=[_bridge_config("uart_0", "cdc_acm_1")], + ), + ) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_debug_on_bridged_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + # The bridge talks to the IDF driver directly, so the uart debugger would see + # nothing and its dummy_receiver would steal RX bytes. + _set_esp32_s3( + set_core_config, + full_config=_full_config(uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {}}]), + ) + with pytest.raises(cv.Invalid, match="debug"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_allows_debug_on_other_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + uarts=[{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1"), CONF_DEBUG: {}}] + ), + ) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) diff --git a/tests/component_tests/climate_ir/__init__.py b/tests/component_tests/climate_ir/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/climate_ir/config/cool_only.yaml b/tests/component_tests/climate_ir/config/cool_only.yaml new file mode 100644 index 00000000000..e40efc811a7 --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false diff --git a/tests/component_tests/climate_ir/config/cool_only_override_on.yaml b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml new file mode 100644 index 00000000000..82c2d7c8812 --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_heat_cool: true diff --git a/tests/component_tests/climate_ir/config/heat_and_cool.yaml b/tests/component_tests/climate_ir/config/heat_and_cool.yaml new file mode 100644 index 00000000000..d92c2ade39b --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool.yaml @@ -0,0 +1,14 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix diff --git a/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml new file mode 100644 index 00000000000..6a72a5981ee --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat_cool: false diff --git a/tests/component_tests/climate_ir/config/heat_only.yaml b/tests/component_tests/climate_ir/config/heat_only.yaml new file mode 100644 index 00000000000..c87a3820b79 --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_cool: false diff --git a/tests/component_tests/climate_ir/config/neither.yaml b/tests/component_tests/climate_ir/config/neither.yaml new file mode 100644 index 00000000000..78830a0a9ba --- /dev/null +++ b/tests/component_tests/climate_ir/config/neither.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_cool: false diff --git a/tests/component_tests/climate_ir/test_supports_heat_cool.py b/tests/component_tests/climate_ir/test_supports_heat_cool.py new file mode 100644 index 00000000000..7ff9381cd6b --- /dev/null +++ b/tests/component_tests/climate_ir/test_supports_heat_cool.py @@ -0,0 +1,53 @@ +"""Tests for the supports_heat_cool default resolved in climate_ir code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _emitted_value(main_cpp: str) -> str | None: + """Return the argument of the generated set_supports_heat_cool() call, or None if absent.""" + match = re.search(r"set_supports_heat_cool\((true|false)\)", main_cpp) + return match.group(1) if match else None + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("heat_and_cool.yaml", None), + ("cool_only.yaml", "false"), + ("heat_only.yaml", "false"), + ("neither.yaml", "false"), + ], +) +def test_default_requires_heat_and_cool( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without the key, HEAT_COOL follows supports_heat and supports_cool.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("cool_only_override_on.yaml", None), + ("heat_and_cool_override_off.yaml", "false"), + ], +) +def test_explicit_key_overrides_default( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A cool-only unit can still offer HEAT_COOL, and a heat+cool unit can drop it.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 00000000000..f7e03743198 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 4f0b786cc28..b5eceeedf67 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -60,7 +60,7 @@ def reset_core() -> Generator[None]: @pytest.fixture(autouse=True) def reset_full_config() -> Generator[None]: """Give each test a clean final-validate config and restore it after.""" - token = final_validate.full_config.set({}) + token = final_validate.full_config.set(Config()) yield final_validate.full_config.reset(token) @@ -75,7 +75,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, - full_config: dict[str, ConfigType] | None = None, + full_config: dict[str, ConfigType] | Config | None = None, ) -> None: platform, framework = platform_framework.value @@ -94,7 +94,12 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: CORE.data[platform.value] = platform_data config.path_context.set([]) - final_validate.full_config.set(full_config or Config()) + # Production always installs a Config (a FinalValidateConfig), never a plain dict. + if not isinstance(full_config, Config): + full = Config() + full.update(full_config or {}) + full_config = full + final_validate.full_config.set(full_config) yield setter diff --git a/tests/component_tests/counter/__init__.py b/tests/component_tests/counter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/counter/config/counter_single.yaml b/tests/component_tests/counter/config/counter_single.yaml new file mode 100644 index 00000000000..41b7f55233d --- /dev/null +++ b/tests/component_tests/counter/config/counter_single.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + on_boot: + then: + - counter.set_value: 5 + - counter.set_value: + value: 7 + - counter.increment: + value: -3 + +esp32: + board: esp32dev + +sensor: + - platform: counter + id: only_counter + name: Only Counter diff --git a/tests/component_tests/counter/config/counter_test.yaml b/tests/component_tests/counter/config/counter_test.yaml new file mode 100644 index 00000000000..b149df42b72 --- /dev/null +++ b/tests/component_tests/counter/config/counter_test.yaml @@ -0,0 +1,42 @@ +esphome: + name: test + on_boot: + then: + - counter.set_value: + id: counter_a + value: 100 + - counter.set_value: + id: counter_b + value: -5000000000 + - counter.increment: counter_a + - counter.increment: + id: counter_a + value: -7 + - counter.increment: + id: counter_b + value: !lambda "return 3;" + +esp32: + board: esp32dev + +sensor: + - platform: template + id: source_sensor + lambda: return 1.0; + - platform: counter + id: counter_a + name: Counter A + sensor: source_sensor + - platform: counter + id: counter_b + name: Counter B + restore: false + initial_value: -5000000000 + - platform: counter + id: counter_c + name: Counter C + binary_sensor: source_binary_sensor + +binary_sensor: + - platform: template + id: source_binary_sensor diff --git a/tests/component_tests/counter/test_counter.py b/tests/component_tests/counter/test_counter.py new file mode 100644 index 00000000000..454f023af1d --- /dev/null +++ b/tests/component_tests/counter/test_counter.py @@ -0,0 +1,99 @@ +"""Tests for the counter sensor.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.counter.sensor import CONFIG_SCHEMA, COUNTER_VALUE + +INT64_MAX = 2**63 - 1 + + +def test_counter_constructor_arguments( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Restore (on by default) and initial value (zero by default) are constructor arguments.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "new(counter_a) counter::CounterSensor(true, 0);" in main_cpp + assert "new(counter_b) counter::CounterSensor(false, -5000000000LL);" in main_cpp + + +def test_counter_sensor_option_registers_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the counter with a sensor option counts that sensor's updates.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "counter_a->count_updates_from(source_sensor);" in main_cpp + assert main_cpp.count("count_updates_from") == 1 + + +def test_counter_binary_sensor_option_registers_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the counter with a binary_sensor option counts that sensor's changes to true.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "counter_c->count_true_from(source_binary_sensor);" in main_cpp + assert main_cpp.count("count_true_from") == 1 + + +def test_counter_actions( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Values reach the counter as 64-bit integers; increment defaults to one.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "::counter_a->set_value(100);" in main_cpp + assert "::counter_b->set_value(-5000000000LL);" in main_cpp + assert "::counter_a->increment(1);" in main_cpp + assert "::counter_a->increment(-7);" in main_cpp + assert "::counter_b->increment(static_cast(3));" in main_cpp + + +def test_counter_actions_without_id_use_only_counter( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With one counter configured, the id may be left out of an action.""" + main_cpp = generate_main(component_config_path("counter_single.yaml")) + + assert "::only_counter->set_value(5);" in main_cpp + assert "::only_counter->set_value(7);" in main_cpp + assert "::only_counter->increment(-3);" in main_cpp + + +@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5]) +def test_counter_initial_value_must_be_int64(value: float) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"id": "c1", "name": "C1", "initial_value": value}) + + +def test_counter_cannot_count_itself() -> None: + """A counter watching its own updates would recurse forever.""" + with pytest.raises(cv.Invalid, match="cannot count its own updates"): + CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "c1"}) + + +def test_counter_accepts_other_source() -> None: + config = CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "other"}) + + assert config["sensor"].id == "other" + + +@pytest.mark.parametrize("value", [0, 1, -1, INT64_MAX, -INT64_MAX]) +def test_counter_value_accepts_int64_range(value: int) -> None: + assert COUNTER_VALUE(value) == value + + +@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5]) +def test_counter_value_rejects_out_of_range(value: float) -> None: + with pytest.raises(cv.Invalid): + COUNTER_VALUE(value) diff --git a/tests/component_tests/dfrobot_sen0395/config/output_latency.yaml b/tests/component_tests/dfrobot_sen0395/config/output_latency.yaml new file mode 100644 index 00000000000..a74527e4b19 --- /dev/null +++ b/tests/component_tests/dfrobot_sen0395/config/output_latency.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + on_boot: + then: + - dfrobot_sen0395.settings: + id: mmwave + output_latency: + delay_after_detect: 0.1s + delay_after_disappear: 5.0s + +esp32: + board: esp32dev + framework: + type: esp-idf + +uart: + tx_pin: GPIO17 + rx_pin: GPIO16 + baud_rate: 115200 + +dfrobot_sen0395: + id: mmwave diff --git a/tests/component_tests/dfrobot_sen0395/test_output_latency.py b/tests/component_tests/dfrobot_sen0395/test_output_latency.py new file mode 100644 index 00000000000..6ce6b6f9743 --- /dev/null +++ b/tests/component_tests/dfrobot_sen0395/test_output_latency.py @@ -0,0 +1,25 @@ +"""Codegen tests for dfrobot_sen0395 output_latency units.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def _latency_return(main_cpp: str, setter: str) -> str: + match = re.search( + rf"{re.escape(setter)}\(\[\]\(\) -> float \{{\s*return ([^;]+);", + main_cpp, + ) + assert match is not None, f"{setter} not found in generated main" + return match.group(1) + + +def test_output_latency_emits_seconds_not_milliseconds( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """YAML 0.1s / 5.0s must reach SetLatencyCommand as seconds, not milliseconds.""" + main_cpp = generate_main(component_config_path("output_latency.yaml")) + + assert _latency_return(main_cpp, "set_delay_after_detect") == "0.1f" + assert _latency_return(main_cpp, "set_delay_after_disappear") == "5.0f" diff --git a/tests/component_tests/epaper_spi/config/full_update_next_test.yaml b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml new file mode 100644 index 00000000000..de5e678a700 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml @@ -0,0 +1,29 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + full_update_every: 5 + dimensions: + width: 200 + height: 200 + +binary_sensor: + - platform: gpio + pin: GPIO27 + name: Trigger + on_press: + - epaper_spi.full_update_next: epaper_display diff --git a/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml b/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml new file mode 100644 index 00000000000..3d15ab8f905 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + dimensions: + width: 200 + height: 200 + border_waveform: 0x1A diff --git a/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml b/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml new file mode 100644 index 00000000000..d1078072986 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +psram: + mode: octal + speed: 80MHz + +spi: + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + id: epaper_display + model: seeed-reterminal-sticky-gray4 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 5e2e7d6013b..9d6ebe831a0 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -312,6 +312,66 @@ def test_model_with_full_update_every( ) +def test_update_interval_below_model_minimum_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """update_interval faster than the model's minimum_update_interval is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="at least"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + "update_interval": "500ms", + } + ) + + +def test_reset_duration_over_max_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """reset_duration over the 500ms cap is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="at most"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + "reset_duration": "600ms", + } + ) + + def test_busy_pin_input_mode_ssd1677( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], @@ -481,6 +541,16 @@ def test_enable_pin_code_generation( assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp +def test_full_update_next_action_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The epaper_spi.full_update_next action targets the configured display.""" + main_cpp = generate_main(component_config_path("full_update_next_test.yaml")) + + assert "epaper_display->request_full_update();" in main_cpp + + def test_model_with_no_default_init_sequence_generates( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/component_tests/epaper_spi/test_model_requirements.py b/tests/component_tests/epaper_spi/test_model_requirements.py new file mode 100644 index 00000000000..26c50a133a0 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_model_requirements.py @@ -0,0 +1,376 @@ +"""Tests for the SSD1677 border_waveform option and EpaperModel.check_requirements().""" + +from collections.abc import Callable, Generator +from pathlib import Path +import re +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.epaper_spi.display import CONFIG_SCHEMA, MODELS +from esphome.components.epaper_spi.models import EpaperModel +from esphome.components.epaper_spi.models.ssd1677 import CONF_BORDER_WAVEFORM +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _ssd1677_config(**overrides: Any) -> ConfigType: + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 200}, + } + config.update(overrides) + return config + + +def _setup_esp32( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + variant: str = VARIANT_ESP32, + board: str = "esp32dev", +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: board, KEY_VARIANT: variant}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + +@pytest.fixture +def temp_model() -> Generator[Callable[..., EpaperModel]]: + """Register a throwaway EpaperModel for a test and remove it from the shared registry after.""" + created: list[EpaperModel] = [] + + def _make(name: str, **defaults: Any) -> EpaperModel: + model = EpaperModel(name, class_name="EPaperMono", **defaults) + created.append(model) + return model + + yield _make + for model in created: + MODELS.pop(model.name, None) + + +# --- border_waveform --------------------------------------------------------- + + +def test_border_waveform_default_mono( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """ssd1677 defaults border_waveform to 0x01.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config()) + + assert result[CONF_BORDER_WAVEFORM] == 0x01 + + +def test_border_waveform_default_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The 4-level grayscale variant defaults border_waveform to 0x00, independently of mono.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + {"id": "test_display", "model": "seeed-reterminal-sticky-gray4"} + ) + + assert result[CONF_BORDER_WAVEFORM] == 0x00 + + +def test_border_waveform_override( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """An explicit border_waveform overrides the model default.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x1A)) + + assert result[CONF_BORDER_WAVEFORM] == 0x1A + + +def test_border_waveform_accepts_hex_string( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """border_waveform accepts a hex string like the YAML author would write.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(border_waveform="0x1A")) + + assert result[CONF_BORDER_WAVEFORM] == 0x1A + + +def test_border_waveform_out_of_range( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """border_waveform rejects values that don't fit in a byte.""" + _setup_esp32(set_core_config, set_component_config) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x100)) + + +def test_border_waveform_in_generated_init_sequence( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The configured border_waveform byte reaches the generated init sequence. + + Command 0x3C (60) is followed by a length of 1 and the waveform byte. + """ + main_cpp = generate_main(component_config_path("ssd1677_border_waveform_test.yaml")) + + assert re.search(r"60,\s*1,\s*0x1A,", main_cpp) + + +# --- full_update_every / supports_partial_update ------------------------------ + + +def test_full_update_every_rejected_for_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The gray4 driver's partial updates are black and white and flatten the whole + panel, so full_update_every > 1 is refused unless that is explicitly accepted.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + with pytest.raises(cv.Invalid, match="monochrome_partial_updates: true"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "full_update_every": 5, + } + ) + + +def test_full_update_every_accepted_for_gray4_with_monochrome_partials( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With the trade-off accepted, the gray4 driver takes full_update_every > 1.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "full_update_every": 5, + "monochrome_partial_updates": True, + } + ) + + assert result["full_update_every"] == 5 + + +def test_monochrome_partial_updates_not_offered_for_mono_sticky( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The option only exists where partial updates lose something.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + with pytest.raises(cv.Invalid, match="monochrome_partial_updates"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky", + "monochrome_partial_updates": True, + } + ) + + +def test_full_update_every_default_accepted_for_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Leaving full_update_every at its default of 1 is fine for the gray4 driver.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + {"id": "test_display", "model": "seeed-reterminal-sticky-gray4"} + ) + + assert result["full_update_every"] == 1 + + +def test_full_update_every_accepted_for_mono_sticky( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The mono sibling model supports partial update, unaffected by the gray4 restriction.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky", + "full_update_every": 5, + } + ) + + assert result["full_update_every"] == 5 + + +# --- check_requirements ------------------------------------------------------- + + +def test_requirement_missing_raises( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """seeed-reterminal-sticky requires psram; without it, config validation fails.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {} + + with pytest.raises(cv.Invalid, match="requires component 'psram'"): + CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + +def test_requirement_satisfied_does_not_raise( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With psram present at the top level, seeed-reterminal-sticky validates.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + assert result["model"] == "SEEED-RETERMINAL-STICKY" + + +def test_requirement_check_skipped_without_raw_config( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With no raw_config (e.g. a schema invoked directly, as in these tests), the + requirement check is a no-op rather than a false failure.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + assert CORE.raw_config is None + + # Should not raise even though "psram" is required and nothing was configured. + CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + +def test_requirement_missing_multiple_pluralised( + temp_model: Callable[..., EpaperModel], +) -> None: + """The error message pluralises "component(s)" and lists every missing one.""" + model = temp_model("test-multi-requirement", requires={"aaa", "bbb"}) + CORE.raw_config = {} + + with pytest.raises(cv.Invalid, match="requires components 'aaa', 'bbb'"): + model.check_requirements() + + +# --- width_multiple ----------------------------------------------------------- + + +@pytest.mark.parametrize("width", [804, 801]) +def test_gray4_width_not_multiple_of_8_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + width: int, +) -> None: + """The gray4 plane split reads two whole buffer bytes per plane byte, so width must be a multiple of 8.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "dimensions": {"width": width, "height": 480}, + } + ) + + +def test_gray4_width_multiple_of_8_accepted( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "dimensions": {"width": 808, "height": 480}, + } + ) + + assert result["dimensions"]["width"] == 808 + + +def test_mono_ssd1677_accepts_any_width( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The width restriction applies only to the gray4 model.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(dimensions={"width": 204, "height": 200})) + + assert result["dimensions"]["width"] == 204 + + +# --- extend(class_name=...) -------------------------------------------------- + + +def test_gray4_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """seeed-reterminal-sticky-gray4 generates the EPaperSSD1677Gray4 driver, not EPaperMono.""" + main_cpp = generate_main(component_config_path("ssd1677_gray4_test.yaml")) + + assert "epaper_spi::EPaperSSD1677Gray4" in main_cpp + assert "epaper_spi::EPaperMono" not in main_cpp diff --git a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml new file mode 100644 index 00000000000..22ee1e480e6 --- /dev/null +++ b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + version: 5.0.6 diff --git a/tests/component_tests/esp32/config/flash_chip_gd.yaml b/tests/component_tests/esp32/config/flash_chip_gd.yaml new file mode 100644 index 00000000000..6d564135c00 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_gd.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: gd diff --git a/tests/component_tests/esp32/config/flash_chip_generic.yaml b/tests/component_tests/esp32/config/flash_chip_generic.yaml new file mode 100644 index 00000000000..8c7bcf61664 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_generic.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: generic diff --git a/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml new file mode 100644 index 00000000000..1531e749f20 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf + advanced: + flash_chip: mxic_opi diff --git a/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml new file mode 100644 index 00000000000..82262f63493 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/mbedtls_tls_default.yaml b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml new file mode 100644 index 00000000000..b29e5de2bd9 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml new file mode 100644 index 00000000000..62ca893d2c5 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml new file mode 100644 index 00000000000..e675848391b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + disable_mbedtls_tls_server: false + disable_mbedtls_tls_extras: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml b/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml new file mode 100644 index 00000000000..44ff047a489 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT: y + CONFIG_MBEDTLS_CCM_C: y + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml new file mode 100644 index 00000000000..6c78e062650 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + eap: + identity: "user@example.org" + username: "user" + password: "secret" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml new file mode 100644 index 00000000000..bcfc1eaf4c8 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_cache_in_psram: false + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml new file mode 100644 index 00000000000..d73b5045da0 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml new file mode 100644 index 00000000000..3e1b32ba410 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ENCRYPTION: "y" + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml new file mode 100644 index 00000000000..86174146d67 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_cache_in_psram: true + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml new file mode 100644 index 00000000000..618d265cc0a --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml new file mode 100644 index 00000000000..b52255c5adb --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml new file mode 100644 index 00000000000..ab4043ccacc --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM: "n" + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/tls_zigbee_c6.yaml b/tests/component_tests/esp32/config/tls_zigbee_c6.yaml new file mode 100644 index 00000000000..3f6904bd983 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_zigbee_c6.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +zigbee: + model: zigbee_test + power_source: MAINS_SINGLE_PHASE diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml new file mode 100644 index 00000000000..8fa28e7c0f4 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml new file mode 100644 index 00000000000..075c3913b53 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + enable_full_printf: true diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 759020c732d..b41234db1a0 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -4,16 +4,21 @@ Test ESP32 configuration import asyncio from collections.abc import Callable +import logging from pathlib import Path from typing import Any import pytest from esphome.components.esp32 import ( + ESP32_FLASH_CHIPS, KEY_FATFS_REQUIRED, + KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, + KEY_MBEDTLS_TLS_SERVER_REQUIRED, KEY_VFS_DIR_REQUIRED, KEY_VFS_SELECT_REQUIRED, KEY_VFS_TERMIOS_REQUIRED, + MBEDTLS_TLS_EXTRA_OPTIONS, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, @@ -249,6 +254,51 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' is only supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_only_on_s3", + ), + pytest.param( + { + "variant": "esp32s3", + "flash_mode": "opi", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "gd"}, + }, + }, + r"'flash_chip: gd' does not match 'flash_mode: opi'; octal flash uses mxic_opi @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_must_match_opi_mode", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' requires 'flash_mode: opi' @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_requires_opi_mode", + ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "flash_mode": "opi", + "framework": {"type": "esp-idf"}, + }, + r"'flash_mode: opi' is only supported on ESP32S3 @ data\['flash_mode'\]", + id="flash_mode_opi_only_on_s3", + ), ], ) def test_esp32_configuration_errors( @@ -655,6 +705,27 @@ def test_platformio_arduino_enables_reproducible_build( assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("reproducible_build.yaml", True), + ("reproducible_build_arduino.yaml", True), + ("file_macro_idf_5_0.yaml", False), + ], +) +def test_file_macro_is_basename_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped.""" + generate_main(component_config_path(config_file)) + + assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected + assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected + + def test_native_idf_enables_reproducible_build( component_config_path: Callable[[str], Path], ) -> None: @@ -680,10 +751,59 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is False assert CORE.platformio_options.get("board_build.flash_mode") == "qio" assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +@pytest.mark.parametrize( + ("config_file", "enabled"), + [ + pytest.param("flash_chip_gd.yaml", "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", id="gd"), + pytest.param("flash_chip_generic.yaml", None, id="generic"), + pytest.param( + "flash_chip_mxic_opi_s3.yaml", + "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", + id="mxic_opi_s3", + ), + ], +) +def test_flash_chip_keeps_one_vendor_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + enabled: str | None, +) -> None: + """flash_chip enables only the chosen vendor driver.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + vendors = { + k: v for k, v in sdkconfig.items() if k.startswith("CONFIG_SPI_FLASH_SUPPORT_") + } + assert vendors == {flag: flag == enabled for flag in ESP32_FLASH_CHIPS.values()} + + +def test_flash_chip_unset_keeps_idf_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_chip every vendor driver stays at its ESP-IDF default.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_SPI_FLASH_SUPPORT_") for key in sdkconfig) + + +def test_flash_mode_opi_enables_octal_flash( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode: opi needs the octal flash switch or ESP-IDF ignores the mode.""" + generate_main(component_config_path("flash_mode_opi_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_OPI") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is True + + def test_flash_mode_unset_leaves_defaults( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], @@ -1338,4 +1458,255 @@ def test_esp32_s31_gpio_validation( pin = {CONF_NUMBER: 36, CONF_MODE: input_mode} with caplog.at_level("WARNING"): validate_supports(pin) - assert "GPIO36 is a strapping PIN" in caplog.text + assert "GPIO36 is a strapping pin" in caplog.text + + +_TLS_SERVER_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", +) + + +@pytest.mark.parametrize( + ("config_file", "server", "extras"), + [ + pytest.param("mbedtls_tls_default.yaml", (True, False), False, id="default"), + pytest.param("mbedtls_tls_opt_out.yaml", (None, None), None, id="opt_out"), + pytest.param("mbedtls_tls_wifi_eap.yaml", (True, False), None, id="wifi_eap"), + ], +) +def test_mbedtls_tls_trim_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + server: tuple[bool | None, bool | None], + extras: bool | None, +) -> None: + """Client-only TLS and the unused-feature trims apply unless opted out or required.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == server + assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras} + + +_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} + + +def test_mbedtls_tls_openthread_keeps_only_what_it_uses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) + for name in MBEDTLS_TLS_EXTRA_OPTIONS: + assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + + +def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed.""" + generate_main(component_config_path("tls_zigbee_c6.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False) + for name in MBEDTLS_TLS_EXTRA_OPTIONS: + assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + + +def test_mbedtls_tls_user_sdkconfig_wins( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A user-set TLS role member leaves the whole choice alone; other user values are kept.""" + generate_main(component_config_path("mbedtls_tls_user_sdkconfig.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is None + role = sdkconfig["CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT"] + assert isinstance(role, RawSdkconfigValue) and role.value == "y" + ccm = sdkconfig["CONFIG_MBEDTLS_CCM_C"] + assert isinstance(ccm, RawSdkconfigValue) and ccm.value == "y" + assert { + sdkconfig.get(name) + for name in MBEDTLS_TLS_EXTRA_OPTIONS + if name != "CONFIG_MBEDTLS_CCM_C" + } == {False} + + +def test_mbedtls_tls_openthread_requires_server_and_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + + +def test_mbedtls_tls_zigbee_requires_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("tls_zigbee_c6.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + + +_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + pytest.param("vasprintf_stub_c6.yaml", True, id="c6"), + pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"), + pytest.param("exclusion_reincludes.yaml", False, id="esp32"), + ], +) +def test_vasprintf_stub_only_on_rom_vsnprintf_variants( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf.""" + generate_main(component_config_path(config_file)) + assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected + defines = {define.name for define in CORE.defines} + assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("nvs_cache_psram_guaranteed.yaml", True), + ("nvs_cache_psram_explicit.yaml", True), + ("nvs_cache_psram_not_guaranteed.yaml", None), + ("nvs_cache_psram_disabled.yaml", None), + # the encryption keys must stay in internal RAM, whichever way encryption is enabled + ("nvs_cache_psram_encrypted.yaml", None), + ("nvs_cache_psram_encrypted_sdkconfig.yaml", None), + ], +) +def test_nvs_cache_in_psram_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + expected: bool | None, +) -> None: + """The NVS cache moves to PSRAM only with guaranteed PSRAM, the option not off and no NVS encryption.""" + generate_main(component_config_path(fixture)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is expected + + +def test_nvs_cache_in_psram_user_sdkconfig_wins( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A raw sdkconfig_options value for the NVS cache option is left alone.""" + generate_main(component_config_path("nvs_cache_psram_user_off.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM"] == RawSdkconfigValue("n") + + +@pytest.mark.parametrize( + ("full_config", "error_match"), + [ + pytest.param( + {CONF_ESPHOME: {}, "psram": {"disabled": False, "ignore_not_found": True}}, + r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'", + id="nvs_cache_in_psram_needs_guaranteed_psram", + ), + pytest.param( + {CONF_ESPHOME: {}}, + r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'", + id="nvs_cache_in_psram_needs_psram", + ), + ], +) +def test_nvs_cache_in_psram_explicit_true_errors( + full_config: dict, error_match: str, set_core_config: SetCoreConfigCallable +) -> None: + """An explicit nvs_cache_in_psram: true that cannot apply is a config error, not a silent no-op.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + config = { + "variant": "esp32s3", + "framework": {"type": "esp-idf", "advanced": {"nvs_cache_in_psram": True}}, + } + with pytest.raises(cv.Invalid, match=error_match): + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_nvs_cache_in_psram_explicit_true_rejects_encryption( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + CONF_ESPHOME: {}, + "psram": {"disabled": False, "ignore_not_found": False}, + }, + ) + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + config = { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_cache_in_psram": True, "nvs_encryption": {"key_id": 0}}, + }, + } + with pytest.raises(cv.Invalid, match="cannot be used with NVS encryption"): + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_nvs_cache_in_psram_default_with_encryption_is_quiet( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Encryption on a board that never mentioned the option must not warn about it.""" + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("nvs_cache_psram_encrypted.yaml")) + assert "nvs_cache_in_psram" not in caplog.text + + +@pytest.mark.asyncio +async def test_nvs_cache_in_psram_explicit_request_warns_when_encrypted( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """An explicit request dropped for NVS encryption enabled elsewhere logs a warning.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_SDKCONFIG_OPTIONS: {}} + ) + from esphome.components.esp32 import ( + _apply_nvs_cache_in_psram, + add_idf_sdkconfig_option, + ) + + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + with caplog.at_level(logging.WARNING): + await _apply_nvs_cache_in_psram(True) + assert "nvs_cache_in_psram ignored" in caplog.text + assert ( + "CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM" + not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + ) + + +def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """An explicit true that applies sets the option and warns about nothing.""" + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("nvs_cache_psram_explicit.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True + assert "nvs_cache_in_psram" not in caplog.text diff --git a/tests/component_tests/esp32_ble_server/config/improv_only.yaml b/tests/component_tests/esp32_ble_server/config/improv_only.yaml index 8a5c3ba6383..4239d24b0fe 100644 --- a/tests/component_tests/esp32_ble_server/config/improv_only.yaml +++ b/tests/component_tests/esp32_ble_server/config/improv_only.yaml @@ -9,5 +9,5 @@ wifi: password: password1 # esp32_ble_server is only auto-loaded here, so it has no services of its own. -esp32_improv: +improv_ble: authorizer: none diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 4b7ab79a81c..21a12d9cf2e 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -55,7 +55,7 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: @pytest.mark.parametrize( ("config_file", "required"), [ - # Auto-loaded by esp32_improv only: nothing to find until Improv asks for it + # Auto-loaded by improv_ble 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), diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 00000000000..9ffc8af736e --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 00000000000..771e5133459 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml new file mode 100644 index 00000000000..7c8f3336844 --- /dev/null +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + id: eth_component + type: KSZ8851SNL + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + cs_pin: GPIO5 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml new file mode 100644 index 00000000000..bc11c6db5b8 --- /dev/null +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + version: 6.0.0 + +ethernet: + id: eth_component + type: KSZ8851SNL + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + cs_pin: GPIO5 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/test_ksz8851snl.py b/tests/component_tests/ethernet/test_ksz8851snl.py new file mode 100644 index 00000000000..ca150100888 --- /dev/null +++ b/tests/component_tests/ethernet/test_ksz8851snl.py @@ -0,0 +1,105 @@ +"""Tests for the KSZ8851SNL SPI ethernet type.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32S3, +) +from esphome.components.esp32.const import ( + KEY_COMPONENTS, + KEY_ESP32, + KEY_REF, + KEY_SDKCONFIG_OPTIONS, +) +from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +_BASE_CONFIG = { + "type": "KSZ8851SNL", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + +_SDKCONFIG_OPTION = "CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL" +_IDF_COMPONENT = "espressif/ksz8851snl" + + +def _sdkconfig() -> dict[str, object]: + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + +def _idf_components() -> dict[str, dict[str, str | None]]: + return CORE.data[KEY_ESP32][KEY_COMPONENTS] + + +def test_codegen_on_idf5_wires_the_spi_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """On IDF 5.x the driver is built into esp_eth and enabled by its Kconfig option.""" + main_cpp = generate_main(component_config_path("ksz8851snl_idf5.yaml")) + + assert "eth_component->set_type(ethernet::ETHERNET_TYPE_KSZ8851SNL);" in main_cpp + assert "eth_component->set_cs_pin(5);" in main_cpp + assert "eth_component->set_interrupt_pin(36);" in main_cpp + assert "USE_ETHERNET_KSZ8851SNL" in {d.name for d in CORE.defines} + assert _sdkconfig()[_SDKCONFIG_OPTION] is True + # Built into IDF 5.x, so the registry component must not be pulled in. + assert _IDF_COMPONENT not in _idf_components() + + +def test_codegen_on_idf6_pulls_the_registry_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """IDF 6.0 dropped the built-in driver, so the managed component is required.""" + generate_main(component_config_path("ksz8851snl_idf6.yaml")) + + assert _idf_components()[_IDF_COMPONENT][KEY_REF] == "1.2.0" + # The Kconfig option no longer exists on IDF 6.0. + assert _SDKCONFIG_OPTION not in _sdkconfig() + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ksz8851snl-test" + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "40MHz"]) +def test_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """The datasheet rates fSCLK to 40MHz, so the whole range must be accepted.""" + _set_esp32_s3(set_core_config) + config = CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + _set_esp32_s3(set_core_config) + with pytest.raises(Invalid, match="value must be at most 40000000"): + CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/component_tests/improv_ble/__init__.py b/tests/component_tests/improv_ble/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/improv_ble/config/automations.yaml b/tests/component_tests/improv_ble/config/automations.yaml new file mode 100644 index 00000000000..d5d97f5cbf3 --- /dev/null +++ b/tests/component_tests/improv_ble/config/automations.yaml @@ -0,0 +1,31 @@ +esphome: + name: improv-ble-automations +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button +output: + - platform: gpio + pin: 2 + id: built_in_led +improv_ble: + authorizer: io0_button + status_indicator: built_in_led + on_provisioned: + - logger.log: provisioned + on_provisioning: + - logger.log: provisioning + on_start: + - logger.log: start + on_state: + - logger.log: state + on_stop: + - logger.log: stop diff --git a/tests/component_tests/improv_ble/config/esp32.yaml b/tests/component_tests/improv_ble/config/esp32.yaml new file mode 100644 index 00000000000..ed55ef358ae --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp32.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-esp32 +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/esp8266.yaml b/tests/component_tests/improv_ble/config/esp8266.yaml new file mode 100644 index 00000000000..d32defd6f33 --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp8266.yaml @@ -0,0 +1,10 @@ +esphome: + name: improv-ble-esp8266 +esp8266: + board: nodemcuv2 +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/legacy_key.yaml b/tests/component_tests/improv_ble/config/legacy_key.yaml new file mode 100644 index 00000000000..9491203ca90 --- /dev/null +++ b/tests/component_tests/improv_ble/config/legacy_key.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-legacy-key +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +esp32_improv: + authorizer: none diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py new file mode 100644 index 00000000000..02293bdb23b --- /dev/null +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -0,0 +1,59 @@ +"""improv_ble is platform neutral; only its BLE server backends are not. + +Covers the platform gate (BLE_SERVER_BACKENDS) and the esp32_improv alias that +keeps pre-rename configurations working. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_esp32_generates_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("esp32.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + + +def test_legacy_key_routes_to_improv_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("legacy_key.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + assert "'esp32_improv:' top-level key is deprecated" in caplog.text + + +def test_platform_without_ble_server_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + # AUTO_LOAD finds no backend for esp8266 and pulls in improv_base only, so + # the platform gate in CONFIG_SCHEMA is what has to reject the config. + CORE.config_path = component_config_path("esp8266.yaml") + assert read_config({}) is None + assert "only available on" in capsys.readouterr().out + + +def test_automations_emit_renamed_triggers( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("automations.yaml")) + for trigger in ( + "ImprovBLEProvisionedTrigger", + "ImprovBLEProvisioningTrigger", + "ImprovBLEStartTrigger", + "ImprovBLEStateTrigger", + "ImprovBLEStoppedTrigger", + ): + assert f"improv_ble::{trigger}" in main_cpp + assert "set_authorizer" in main_cpp + assert "set_status_indicator" in main_cpp diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 00000000000..ecb33b0ea80 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_color_name.py b/tests/component_tests/light/test_color_name.py new file mode 100644 index 00000000000..4f6fda686e4 --- /dev/null +++ b/tests/component_tests/light/test_color_name.py @@ -0,0 +1,119 @@ +"""Tests for the light `color` option.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome import config_validation as cv +from esphome.components.light.automation import ( + LIGHT_CONTROL_ACTION_SCHEMA, + LIGHT_STATE_SCHEMA, +) +from esphome.const import ( + CONF_BLUE, + CONF_COLOR_BRIGHTNESS, + CONF_GREEN, + CONF_ID, + CONF_RED, +) + +LOGGER_NAME: str = "esphome.components.light.automation" + + +def test_color_name_sets_rgb() -> None: + result = LIGHT_STATE_SCHEMA({"color": "Tomato"}) + assert "color" not in result + assert result[CONF_RED] == 1.0 + assert result[CONF_GREEN] == pytest.approx(0x63 / 0xFF) + assert result[CONF_BLUE] == pytest.approx(0x47 / 0xFF) + assert result[CONF_COLOR_BRIGHTNESS] == 1.0 + + +def test_color_name_in_control_action() -> None: + result = LIGHT_CONTROL_ACTION_SCHEMA({CONF_ID: "test_light", "color": "blue"}) + assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 1.0) + + +def test_unknown_color_name() -> None: + with pytest.raises(cv.Invalid, match="notacolor"): + LIGHT_STATE_SCHEMA({"color": "notacolor"}) + + +def test_color_name_conflicts_with_rgb() -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + LIGHT_STATE_SCHEMA({"color": "red", CONF_GREEN: 0.5}) + + +def test_dark_color_sets_color_brightness() -> None: + result = LIGHT_STATE_SCHEMA({"color": "darkred"}) + assert result[CONF_RED] == 1.0 + assert result[CONF_GREEN] == 0.0 + assert result[CONF_BLUE] == 0.0 + assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF) + + +def test_black_sets_zero_color_brightness() -> None: + result = LIGHT_STATE_SCHEMA({"color": "black"}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.0 + assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 0) + + +def test_explicit_color_brightness_wins(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER_NAME): + result = LIGHT_STATE_SCHEMA({"color": "darkred", CONF_COLOR_BRIGHTNESS: 0.25}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.25 + assert "overrides the brightness of color 'darkred'" in caplog.text + + +def test_explicit_color_brightness_full_color( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER_NAME): + result = LIGHT_STATE_SCHEMA({"color": "red", CONF_COLOR_BRIGHTNESS: 0.25}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.25 + assert not [r for r in caplog.records if r.name == LOGGER_NAME] + + +@pytest.mark.parametrize("value", [0x223344, "0x223344", "0X223344"]) +def test_hex_color(value: int | str) -> None: + result = LIGHT_STATE_SCHEMA({"color": value}) + assert "color" not in result + assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x44 / 0xFF) + assert result[CONF_BLUE] == 1.0 + assert result[CONF_RED] == pytest.approx(0x22 / 0x44) + assert result[CONF_GREEN] == pytest.approx(0x33 / 0x44) + + +def test_hex_color_full_level() -> None: + result = LIGHT_STATE_SCHEMA({"color": 0xFF8000}) + assert result[CONF_COLOR_BRIGHTNESS] == 1.0 + assert result[CONF_RED] == 1.0 + assert result[CONF_GREEN] == pytest.approx(0x80 / 0xFF) + assert result[CONF_BLUE] == 0.0 + + +def test_hex_color_black() -> None: + result = LIGHT_STATE_SCHEMA({"color": 0}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.0 + assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 0) + + +@pytest.mark.parametrize("value", [0x1000000, -1, "0xGG", "0x1000000"]) +def test_hex_color_invalid(value: int | str) -> None: + with pytest.raises(cv.Invalid): + LIGHT_STATE_SCHEMA({"color": value}) + + +def test_hex_color_conflicts_with_rgb() -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + LIGHT_STATE_SCHEMA({"color": 0x223344, CONF_RED: 0.5}) + + +def test_explicit_color_brightness_warning_names_hex_color( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER_NAME): + LIGHT_STATE_SCHEMA({"color": 0x223344, CONF_COLOR_BRIGHTNESS: 0.25}) + assert "overrides the brightness of color '0x223344'" in caplog.text diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 00000000000..a4fc24a7cbf --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp diff --git a/tests/component_tests/light/test_light_state_layout.py b/tests/component_tests/light/test_light_state_layout.py new file mode 100644 index 00000000000..2d6c6e1523e --- /dev/null +++ b/tests/component_tests/light/test_light_state_layout.py @@ -0,0 +1,37 @@ +"""Limits and flash layout that let LightState stay small.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light.effects import ( + MAX_EFFECTS, + MONOCHROMATIC_EFFECTS, + validate_effects, +) + + +def _effects(count: int) -> list[dict[str, dict[str, str]]]: + return [{"pulse": {"name": f"Pulse {i}"}} for i in range(count)] + + +def test_rejects_more_effects_than_the_index_holds() -> None: + with pytest.raises(cv.Invalid, match=f"at most {MAX_EFFECTS} effects"): + validate_effects(MONOCHROMATIC_EFFECTS)(_effects(MAX_EFFECTS + 1)) + + +def test_accepts_a_normal_effect_list() -> None: + assert len(validate_effects(MONOCHROMATIC_EFFECTS)(_effects(3))) == 3 + + +def test_gamma_table_initializer_holds_the_lut_then_gamma_times_100() -> None: + from esphome.components.light import gamma_table_initializer, generate_gamma_table + + init = gamma_table_initializer(2.8) + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(2.8)) + assert init == f"{{{{{lut}}}, 280}}" + + +def test_gamma_table_initializer_saturates_gamma_that_overflows_uint16() -> None: + from esphome.components.light import gamma_table_initializer + + assert gamma_table_initializer(1000.0).endswith(", 65535}") diff --git a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py index 608a4c66941..c1ef283ea33 100644 --- a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -34,12 +34,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7bc..199d67ff5ca 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -1,7 +1,13 @@ """Tests for the logger component.""" +from collections.abc import Callable +from pathlib import Path import re +import pytest + +from esphome.core import CORE + def test_logger_pre_setup_before_other_components(generate_main): """Logger::pre_setup() must be called before any other component is created. @@ -52,3 +58,56 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp + + +def test_ram_log_strings_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`false` is accepted but ignored: no RAM build flag, and a warning with the removal version.""" + generate_main("tests/component_tests/logger/test_logger_ram_log_strings.yaml") + + assert not any("STORE_LOG_STR" in flag for flag in CORE.build_flags) + assert "esp8266_store_log_strings_in_flash: false' is ignored" in caplog.text + assert "2027.4.0" in caplog.text + + +def test_flash_log_strings_default_does_not_warn( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "esp8266_store_log_strings_in_flash" not in caplog.text diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 00000000000..1f11ea4580c --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 00000000000..dc25fe99ce2 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_ram_log_strings.yaml b/tests/component_tests/logger/test_logger_ram_log_strings.yaml new file mode 100644 index 00000000000..ef6a8e2da17 --- /dev/null +++ b/tests/component_tests/logger/test_logger_ram_log_strings.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + esp8266_store_log_strings_in_flash: false diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 00000000000..ce45a6ae3fb --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 diff --git a/tests/component_tests/lvgl/config/debug_outline.yaml b/tests/component_tests/lvgl/config/debug_outline.yaml new file mode 100644 index 00000000000..eb8a488c139 --- /dev/null +++ b/tests/component_tests/lvgl/config/debug_outline.yaml @@ -0,0 +1,36 @@ +esphome: + name: test-debug-outline + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + debug_outline: true + widgets: + - obj: + id: root_obj + widgets: + - label: + id: title_label + text: Title + - button: + id: ok_button + widgets: + - label: + text: OK diff --git a/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml b/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml new file mode 100644 index 00000000000..8f80a942f5d --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml @@ -0,0 +1,28 @@ +esphome: + name: test-keyboard-before-textarea + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: GPIO3 + +lvgl: + widgets: + - keyboard: + id: kb + textarea: ta + - textarea: + id: ta diff --git a/tests/component_tests/lvgl/config/keyboard_no_label.yaml b/tests/component_tests/lvgl/config/keyboard_no_label.yaml new file mode 100644 index 00000000000..7a45a537d3d --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_no_label.yaml @@ -0,0 +1,32 @@ +esphome: + name: test-keyboard-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - keyboard: + id: keyboard_widget diff --git a/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml b/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml new file mode 100644 index 00000000000..1d02a2df587 --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml @@ -0,0 +1,34 @@ +esphome: + name: test-keyboard-update-textarea + on_boot: + - lvgl.keyboard.update: + id: kb + textarea: ta2 + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: GPIO3 + +lvgl: + widgets: + - keyboard: + id: kb + textarea: ta1 + - textarea: + id: ta1 + - textarea: + id: ta2 diff --git a/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml b/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml new file mode 100644 index 00000000000..d30f1ff29a1 --- /dev/null +++ b/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-multi-instance-theme + +host: + mac_address: "62:23:45:AF:B3:DD" + +display: + - platform: snapshot + id: sdl0 + dimensions: + width: 100 + height: 100 + - platform: snapshot + id: sdl1 + dimensions: + width: 100 + height: 100 + +lvgl: + - id: lvgl_0 + displays: sdl0 + widgets: + - label: + id: label_0 + text: "First" + + - id: lvgl_1 + displays: sdl1 + theme: + label: + bg_color: 0xFF0000 + widgets: + - label: + id: label_1 + text: "Second" diff --git a/tests/component_tests/lvgl/config/no_debug_outline.yaml b/tests/component_tests/lvgl/config/no_debug_outline.yaml new file mode 100644 index 00000000000..2ea646bcb23 --- /dev/null +++ b/tests/component_tests/lvgl/config/no_debug_outline.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-debug-outline + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - obj: + id: root_obj + widgets: + - label: + id: title_label + text: Title + - button: + id: ok_button + widgets: + - label: + text: OK diff --git a/tests/component_tests/lvgl/config/qrcode_no_label.yaml b/tests/component_tests/lvgl/config/qrcode_no_label.yaml new file mode 100644 index 00000000000..8bb1aafdd6e --- /dev/null +++ b/tests/component_tests/lvgl/config/qrcode_no_label.yaml @@ -0,0 +1,34 @@ +esphome: + name: test-qrcode-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - qrcode: + id: qr_widget + size: 100 + text: "esphome.io" diff --git a/tests/component_tests/lvgl/config/tabview_no_label.yaml b/tests/component_tests/lvgl/config/tabview_no_label.yaml new file mode 100644 index 00000000000..a3c16ab3471 --- /dev/null +++ b/tests/component_tests/lvgl/config/tabview_no_label.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-tabview-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - tabview: + id: tabview_widget + tabs: + - name: "Tab 1" + id: tab_1 diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py index ce9a162d993..a150f66a40e 100644 --- a/tests/component_tests/lvgl/test_animation.py +++ b/tests/component_tests/lvgl/test_animation.py @@ -191,6 +191,24 @@ class TestTimingSchema: result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5}) assert result["weight"] == pytest.approx(0.5) + def test_ease_in_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in") + assert result["type"] == "ease_in" + assert result["weight"] == pytest.approx(1.0) + + def test_ease_in_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) + + def test_ease_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_out") + assert result["type"] == "ease_out" + assert result["weight"] == pytest.approx(1.0) + + def test_ease_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_out", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) + def test_gravity_defaults(self) -> None: result = TIMING_SCHEMA("gravity") assert result["type"] == "gravity" diff --git a/tests/component_tests/lvgl/test_debug_outline.py b/tests/component_tests/lvgl/test_debug_outline.py new file mode 100644 index 00000000000..78eb3c077f7 --- /dev/null +++ b/tests/component_tests/lvgl/test_debug_outline.py @@ -0,0 +1,33 @@ +"""Tests for the LVGL ``debug_outline`` option code generation.""" + +from __future__ import annotations + +import re + +_OUTLINE_COLOR_RE = re.compile( + r"lv_obj_set_style_outline_color\(.+?, (lv_color_make\(.+?\))," +) +_OUTLINE_WIDTH_RE = re.compile(r"lv_obj_set_style_outline_width\(") +_OUTLINE_PAD_RE = re.compile(r"lv_obj_set_style_outline_pad\(.+?, 0,") + + +class TestDebugOutlineCodeGeneration: + """Verify that ``debug_outline`` outlines every widget in a distinct colour.""" + + def test_debug_outline_styles_every_widget( + self, generate_main, component_config_path + ): + """Four widgets are declared, so four outlines with different colours appear.""" + main_cpp = generate_main(component_config_path("debug_outline.yaml")) + assert len(_OUTLINE_WIDTH_RE.findall(main_cpp)) == 4 + assert len(_OUTLINE_PAD_RE.findall(main_cpp)) == 4 + colors = _OUTLINE_COLOR_RE.findall(main_cpp) + assert len(colors) == 4 + assert len(set(colors)) == 4 + + def test_debug_outline_default_emits_nothing( + self, generate_main, component_config_path + ): + """Without ``debug_outline`` no outline styles are generated.""" + main_cpp = generate_main(component_config_path("no_debug_outline.yaml")) + assert "lv_obj_set_style_outline_" not in main_cpp diff --git a/tests/component_tests/lvgl/test_keyboard_before_textarea.py b/tests/component_tests/lvgl/test_keyboard_before_textarea.py new file mode 100644 index 00000000000..3da4b1cebe8 --- /dev/null +++ b/tests/component_tests/lvgl/test_keyboard_before_textarea.py @@ -0,0 +1,47 @@ +"""Regression test: a keyboard: declared before its textarea: sibling must +still get attached to it, and only after both widgets exist. + +attach_textareas() runs as a deferred pass, after every widget (across every +LVGL instance) is created, so it must emit the attach call after the +keyboard's own creation statement, not inline during widget creation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent / "config" / "keyboard_before_textarea_test.yaml" + ) + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_keyboard_and_textarea_are_created(main_cpp: str) -> None: + assert "lv_keyboard_create(" in main_cpp + assert "lv_textarea_create(" in main_cpp + + +def test_attach_call_runs_after_keyboard_and_textarea_are_created( + main_cpp: str, +) -> None: + attach_index = main_cpp.find("lv_keyboard_set_textarea(kb->obj, ta);") + assert attach_index != -1, "keyboard was never attached to its textarea" + assert attach_index > main_cpp.find("lv_keyboard_create(") + assert attach_index > main_cpp.find("lv_textarea_create(") diff --git a/tests/component_tests/lvgl/test_keyboard_update_textarea.py b/tests/component_tests/lvgl/test_keyboard_update_textarea.py new file mode 100644 index 00000000000..154d2ee2004 --- /dev/null +++ b/tests/component_tests/lvgl/test_keyboard_update_textarea.py @@ -0,0 +1,33 @@ +"""Regression test: lvgl.keyboard.update must be able to change which +textarea a keyboard is attached to. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent / "config" / "keyboard_update_textarea_test.yaml" + ) + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_keyboard_update_changes_textarea(main_cpp: str) -> None: + assert "lv_keyboard_set_textarea(kb->obj, ta2);" in main_cpp diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py index 93ee162f1af..368a3a49905 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -286,7 +286,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None: def test_add_button_with_checkable_flag(main_cpp: str) -> None: - assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp + assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp assert ( "lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));" in main_cpp @@ -310,7 +310,7 @@ def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None: assert ( "lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n" " lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, " - "(lv_state_t)(LV_PART_MAIN));\n" + "LV_PART_MAIN);\n" ' lv_label_set_text(dyn_label_1_VAR_, "Nested");' ) in main_cpp @@ -321,7 +321,7 @@ def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None: """ assert ( "lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, " - "(lv_state_t)(LV_PART_MAIN));" + "LV_PART_MAIN);" ) in main_cpp assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp @@ -343,7 +343,7 @@ def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> Non assert ( "lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n" " lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, " - "(lv_state_t)(LV_PART_MAIN));\n" + "LV_PART_MAIN);\n" ' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");' ) in main_cpp diff --git a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py index 2383adc2518..9ff7b8736e0 100644 --- a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py +++ b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py @@ -1,16 +1,4 @@ -"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock. - -ListType.to_code() used to build the on_add/on_remove automations directly, during -widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only -resolves once *all* widgets - including the list itself - have finished being -created. Building an automation containing an lvgl action from inside that same -widget-creation walk therefore could never complete: codegen deadlocked with -"Circular dependency detected!". Fixed by deferring the actual build_automation() -call to finish_list_triggers(), run after set_widgets_completed(True) - and, -critically, before generate_triggers(), which is what processes other widgets' -on_click etc. automations that might reference this list (e.g. via lvgl.list.add), -and which therefore need the list's own on_add/on_remove triggers to already exist. -""" +"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.""" from __future__ import annotations diff --git a/tests/component_tests/lvgl/test_lv_conf_build_flags.py b/tests/component_tests/lvgl/test_lv_conf_build_flags.py new file mode 100644 index 00000000000..eb576fa8fac --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_build_flags.py @@ -0,0 +1,59 @@ +"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option +the configuration does not use, so ``lv_conf.h`` must leave alone the options +the user defines in build flags. Otherwise the header, which is included after +the compiler ``-D`` flags, turns the option off again. + +Build flags can come from ``esphome: build_flags:`` or from the deprecated +``esphome: platformio_options: build_flags:``; both must be honoured. +""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lvgl import defines as df, generate_lv_conf_h +from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS +from esphome.core import CORE + + +def _set_esphome_config( + build_flags: list[str] | None = None, + pio_build_flags: list[str] | str | None = None, +) -> None: + pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags} + CORE.config = { + CONF_ESPHOME: { + CONF_PLATFORMIO_OPTIONS: pio_options, + CONF_BUILD_FLAGS: build_flags or [], + } + } + + +def test_unused_define_is_disabled_without_build_flag() -> None: + _set_esphome_config() + assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines() + + +@pytest.mark.parametrize( + "flags", + [ + {"build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"build_flags": ["-D LV_USE_OBSERVER"]}, + {"pio_build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"pio_build_flags": "-DLV_USE_OBSERVER=1"}, + ], + ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"], +) +def test_build_flag_define_is_not_disabled(flags: dict) -> None: + _set_esphome_config(**flags) + assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines() + + +def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None: + _set_esphome_config(build_flags=["-DLV_USE_ARC=1"]) + df.add_define("LV_USE_ARC") + with caplog.at_level(logging.WARNING): + generate_lv_conf_h() + assert "LV_USE_ARC" in caplog.text diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index b63b7618e7d..c44b40317e8 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -9,13 +9,16 @@ from esphome.components.lvgl.schemas import theme_schema from esphome.config_validation import Invalid -def _config(displays: list[str], theme: dict | None = None) -> dict: +def _config( + displays: list[str], theme: dict | None = None, debug_outline: bool = False +) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, + df.CONF_DEBUG_OUTLINE: debug_outline, } if theme is not None: config[df.CONF_THEME] = theme @@ -53,3 +56,20 @@ class TestThemeOnMultipleInstances: def test_passes_when_no_instance_has_theme(self) -> None: configs = [_config(["disp_a"]), _config(["disp_b"])] multi_conf_validate(configs) + + +class TestDebugOutlineOnMultipleInstances: + def test_raises_when_instances_differ(self) -> None: + configs = [ + _config(["disp_a"], debug_outline=True), + _config(["disp_b"], debug_outline=False), + ] + with pytest.raises(Invalid, match="'debug_outline' must be the same"): + multi_conf_validate(configs) + + def test_passes_when_instances_match(self) -> None: + configs = [ + _config(["disp_a"], debug_outline=True), + _config(["disp_b"], debug_outline=True), + ] + multi_conf_validate(configs) diff --git a/tests/component_tests/lvgl/test_multi_instance_theme.py b/tests/component_tests/lvgl/test_multi_instance_theme.py new file mode 100644 index 00000000000..82f271b6059 --- /dev/null +++ b/tests/component_tests/lvgl/test_multi_instance_theme.py @@ -0,0 +1,44 @@ +"""Regression test: a widget's theme style must be attached regardless of +which LVGL instance declares theme: and which instance's widgets are built +first. + +theme_to_code() runs once per LVGL instance, interleaved with that instance's +own add_widgets(). A widget built by an earlier instance, before any instance +has declared theming for its type, must still pick up a later instance's +theme: declaration for that type - including on the later instance's own +widgets, which get_widget_theme_styles()'s memoisation can poison too. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent / "config" / "multi_instance_theme_test.yaml" + ) + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_earlier_instance_widget_is_themed(main_cpp: str) -> None: + assert "lv_obj_add_style(label_0, _lv_theme_style_label_main_default," in main_cpp + + +def test_later_instance_widget_is_themed(main_cpp: str) -> None: + assert "lv_obj_add_style(label_1, _lv_theme_style_label_main_default," in main_cpp diff --git a/tests/component_tests/lvgl/test_widget_label_dependency.py b/tests/component_tests/lvgl/test_widget_label_dependency.py new file mode 100644 index 00000000000..9d3e24c8c5a --- /dev/null +++ b/tests/component_tests/lvgl/test_widget_label_dependency.py @@ -0,0 +1,32 @@ +"""Widgets whose LVGL C implementation creates or references labels +internally (tab titles, key legends, the QR canvas fallback) must declare +the label dependency in ``get_uses()``. Otherwise a config that contains +no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and +fails at C compile time with undefined ``lv_label_*`` symbols. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df + + +@pytest.mark.parametrize( + "yaml_file", + [ + "qrcode_no_label.yaml", + "keyboard_no_label.yaml", + "tabview_no_label.yaml", + ], +) +def test_label_less_config_enables_lv_use_label( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, +) -> None: + generate_main(component_config_path(yaml_file)) + assert "LV_USE_LABEL" in df.get_defines() diff --git a/tests/component_tests/mdns/__init__.py b/tests/component_tests/mdns/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py new file mode 100644 index 00000000000..eacf722f177 --- /dev/null +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -0,0 +1,70 @@ +"""request_service_enable_disable() only opts in on platforms whose mDNS stack +can add and remove services after setup, and tells the caller so.""" + +import pytest + +from esphome.components import mdns +from esphome.const import CONF_DISABLED, PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE" + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def _set_config( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + config: dict, +) -> None: + set_core_config(platform_framework) + CORE.config = config + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO], +) +def test_esp32_adds_define_and_keeps_services_stored( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is True + # Disabled services must stay stored so they can be re-registered later. + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], +) +def test_other_platforms_return_false( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="no_mdns"), + pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"), + pytest.param( + {"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread" + ), + ], +) +def test_esp32_returns_false_when_services_cannot_be_toggled( + set_core_config: SetCoreConfigCallable, config: dict +) -> None: + _set_config(set_core_config, PlatformFramework.ESP32_IDF, config) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index ac8e111ddba..e677577ec08 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -1,5 +1,7 @@ """Tests for mipi_rgb configuration validation.""" +from collections.abc import Generator + import pytest from esphome import config_validation as cv @@ -17,6 +19,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, VARIANT_ESP32S31, ) +from esphome.components.mipi import DriverChip import esphome.components.pca9554 # noqa: F401 import esphome.components.xl9535 # noqa: F401 from esphome.const import ( @@ -44,6 +47,19 @@ DATA_PINS = { } +@pytest.fixture(autouse=True) +def _remove_test_models() -> Generator[None]: + """Unregister chips created by a test. + + display.py modules drain DriverChip.models when first imported, so a + leftover TEST-* chip could become a selectable model there. + """ + existing = set(DriverChip.models) + yield + for name in set(DriverChip.models) - existing: + del DriverChip.models[name] + + def _set_s3(set_core_config: SetCoreConfigCallable) -> None: set_core_config( PlatformFramework.ESP32_IDF, @@ -176,6 +192,41 @@ def test_configuration_succeeds_on_supported_variants( CONFIG_SCHEMA(config) +def test_st7701s_default_reset_delay() -> None: + """ST7701S instances default to a 50ms reset delay. + + The datasheet's stated 5ms is too short in practice; ST7701S overrides the + DriverChip default of 10ms with its own default of 50ms. + """ + from esphome.components.mipi_rgb.models.st7701s import st7701s + + assert st7701s.get_default("reset_delay") == 50 + + +def test_st7701s_reset_delay_can_be_overridden() -> None: + """An explicit reset_delay overrides the ST7701S default of 50ms.""" + from esphome.components.mipi_rgb.models.st7701s import ST7701S + + chip = ST7701S("TEST-ST7701S-RESET-DELAY", width=480, height=480, reset_delay=99) + + assert chip.get_default("reset_delay") == 99 + + +def test_st7701s_extend_inherits_reset_delay_default() -> None: + """extend() carries the 50ms default forward to derived board models. + + Every shipped ST7701S variant is built via ``st7701s.extend(...)`` rather + than direct construction, so the override in ``ST7701S.__init__`` must + survive that path (see DriverChip.extend, which re-passes the copied + defaults as kwargs to the constructor). + """ + from esphome.components.mipi_rgb.models.st7701s import st7701s + + extended = st7701s.extend("TEST-ST7701S-EXTEND", width=480, height=480) + + assert extended.get_default("reset_delay") == 50 + + def test_only_on_variant_rejects_unsupported_variant( set_core_config: SetCoreConfigCallable, ) -> None: diff --git a/tests/component_tests/mipi_rgb/test_reset_sequence.py b/tests/component_tests/mipi_rgb/test_reset_sequence.py new file mode 100644 index 00000000000..50478361b14 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_reset_sequence.py @@ -0,0 +1,81 @@ +"""End-to-end tests for the mipi_rgb SPI reset sequence. + +These exercise the actual codegen path (mipi_rgb/display.py's +``model.get_sequence(config, add_reset=True)`` call) rather than calling +DriverChip.get_sequence directly, so a regression that drops add_reset or +reintroduces a hardcoded SWRESET into a model's initsequence would be caught +here. +""" + +from collections.abc import Callable +from pathlib import Path + +# A model with no reset_pin default: SWRESET ({1, 0}) is prepended ahead of the +# inherited ST7701S reset_delay ({50, 255}). +_NO_RESET_PIN_YAML = """ +esphome: + name: mipi-rgb-reset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +psram: + mode: octal +spi: + id: spi_bus + clk_pin: 10 + mosi_pin: 11 +display: + - platform: mipi_rgb + id: no_reset_display + spi_id: spi_bus + model: MAKERFABS-4 +""" + +# A model with a reset_pin default: no SWRESET, just the settling delay. +_RESET_PIN_YAML = """ +esphome: + name: mipi-rgb-reset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +psram: + mode: octal +spi: + id: spi_bus + clk_pin: 6 + mosi_pin: 7 +display: + - platform: mipi_rgb + id: has_reset_display + spi_id: spi_bus + model: WAVESHARE-3.16-320X820 +""" + + +def test_swreset_and_reset_delay_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset_pin gets SWRESET plus the ST7701S 50ms delay.""" + yaml_file = tmp_path / "no_reset.yaml" + yaml_file.write_text(_NO_RESET_PIN_YAML) + + main_cpp = generate_main(yaml_file) + + assert "no_reset_display->set_init_sequence({1, 0, 50, 255," in main_cpp + + +def test_reset_delay_only_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with a reset_pin default skips SWRESET but keeps the settling delay.""" + yaml_file = tmp_path / "has_reset.yaml" + yaml_file.write_text(_RESET_PIN_YAML) + + main_cpp = generate_main(yaml_file) + + assert "has_reset_display->set_init_sequence({50, 255," in main_cpp + assert "has_reset_display->set_init_sequence({1, 0," not in main_cpp diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py new file mode 100644 index 00000000000..263b567b9c9 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -0,0 +1,64 @@ +"""Tests for DriverChip.get_sequence's reset-delay handling.""" + +from collections.abc import Generator + +import pytest + +from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip + +# A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run +# without needing a full display configuration. +_BASE_CONFIG = {CONF_PIXEL_MODE: "16bit", CONF_INVERT_COLORS: False} + + +@pytest.fixture(autouse=True) +def _remove_test_models() -> Generator[None]: + """Unregister chips created by a test.""" + existing = set(DriverChip.models) + yield + for name in set(DriverChip.models) - existing: + del DriverChip.models[name] + + +def test_get_sequence_defaults_to_10ms_reset_delay() -> None: + """A model with no reset_delay default falls back to a 10ms settling delay.""" + chip = DriverChip("TEST-GET-SEQUENCE-DEFAULT") + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True) + + # SWRESET ({1, 0}) is prepended (no reset pin configured), followed by the + # 10ms settling delay, flattened to {10, 255}. + assert sequence[:4] == (1, 0, 10, 255) + + +def test_get_sequence_uses_model_reset_delay_default() -> None: + """A model's own reset_delay default overrides the base 10ms default.""" + chip = DriverChip("TEST-GET-SEQUENCE-CUSTOM-DELAY", reset_delay=99) + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True) + + assert sequence[:4] == (1, 0, 99, 255) + + +@pytest.mark.parametrize("reset_delay", [0, 256]) +def test_get_sequence_rejects_out_of_range_reset_delay(reset_delay: int) -> None: + """reset_delay outside 1-255ms is rejected. + + This matches the 1-255ms range map_sequence() already allows for a + "delay N" entry in a custom init sequence. + """ + chip = DriverChip("TEST-GET-SEQUENCE-BAD-DELAY", reset_delay=reset_delay) + + with pytest.raises(ValueError, match="reset_delay must be between 1 and 255ms"): + chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True) + + +def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None: + """An out-of-range reset_delay is only checked when add_reset is requested. + + mipi_dsi calls get_sequence with add_reset=False and never uses + reset_delay, so an invalid default there should not raise. + """ + chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999) + + chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False) diff --git a/tests/component_tests/mk2pvrouter/__init__.py b/tests/component_tests/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mk2pvrouter/test_sensor_defaults.py b/tests/component_tests/mk2pvrouter/test_sensor_defaults.py new file mode 100644 index 00000000000..165adb676a7 --- /dev/null +++ b/tests/component_tests/mk2pvrouter/test_sensor_defaults.py @@ -0,0 +1,84 @@ +"""Tag-based sensor defaults, driven through the real CONFIG_SCHEMA.""" + +import pytest + +from esphome.components import sensor +from esphome.components.mk2pvrouter.sensor import CONFIG_SCHEMA, tag_kind +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_EMPTY, + STATE_CLASS_MEASUREMENT, +) +from esphome.types import ConfigType + + +def _sensor(tag: str, **extra: object) -> ConfigType: + return CONFIG_SCHEMA( + {"tag": tag, "mk2pvrouter_id": "hub", "name": f"{tag} sensor", **extra} + ) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class", "decimals"), + [ + ("P", "W", "power", "measurement", 0), + ("P1", "W", "power", "measurement", 0), + ("D", "W", "power", "measurement", 0), + ("D1", "%", "", "measurement", 0), + ("V", "V", "voltage", "measurement", 2), + ("v1", "V", "voltage", "measurement", 2), + ("E", "Wh", "energy", "total_increasing", 0), + ("T1", "°C", "temperature", "measurement", 2), + ("R", "W", "power", "measurement", 0), + ("R1", "", "", "", 0), + ("R10", "", "", "", 0), + ], +) +def test_tag_defaults( + tag: str, unit: str, device_class: str, state_class: str, decimals: int +) -> None: + config = _sensor(tag) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == sensor.validate_state_class(state_class) + assert config[CONF_ACCURACY_DECIMALS] == decimals + + +@pytest.mark.parametrize("tag", ["S_MC", "STATUS", "X9", "Z", "T", "E1"]) +def test_unknown_tag_gets_only_the_schema_defaults(tag: str) -> None: + config = _sensor(tag) + assert CONF_UNIT_OF_MEASUREMENT not in config + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_MEASUREMENT + ) + assert config[CONF_ACCURACY_DECIMALS] == 0 + + +def test_explicit_values_win_over_tag_defaults() -> None: + config = _sensor("P", device_class=DEVICE_CLASS_EMPTY, accuracy_decimals=3) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_EMPTY + assert config[CONF_ACCURACY_DECIMALS] == 3 + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + + +@pytest.mark.parametrize( + ("tag", "scale_centi"), + [ + ("V", True), + ("V1", True), + ("v2", True), + ("T1", True), + ("P", False), + ("D1", False), + ("E", False), + ("T", False), + ("X9", False), + ], +) +def test_centi_scale_follows_the_tag(tag: str, scale_centi: bool) -> None: + kind = tag_kind(tag) + assert (kind is not None and kind.scale_centi) is scale_centi diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50b..1eafb131664 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read. schema = modbus.modbus_device_schema(0x01) assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825e..fcccae144e0 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 00000000000..01bdacbf863 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f4..592f6c12bad 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py index f2c0f263442..c502e06dab4 100644 --- a/tests/component_tests/motion/test_motion.py +++ b/tests/component_tests/motion/test_motion.py @@ -18,7 +18,6 @@ from esphome.components.motion import ( _build_calibrate_action, _transform_matrix, _validate_matrix_options, - clear_calibration_to_code, ) from esphome.components.motion.sensor import ( _ACCELERATIONS, @@ -785,44 +784,6 @@ class TestClearActionSchema: assert result[CONF_SAVE] is False -@pytest.fixture -def mock_clear_codegen(): - """Mock cg functions used by clear_calibration_to_code.""" - mock_var = MagicMock() - mock_parent = MagicMock() - with ( - patch( - "esphome.components.motion.cg.get_variable", - new_callable=AsyncMock, - return_value=mock_parent, - ), - patch( - "esphome.components.motion.cg.new_Pvariable", - return_value=mock_var, - ) as mock_new_pvar, - patch("esphome.components.motion.cg.add") as mock_add, - ): - yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var} - - -@pytest.mark.asyncio -async def test_clear_action_without_save(mock_clear_codegen): - """With save=False, set_save should not be emitted.""" - config = {CONF_ID: MagicMock(), CONF_SAVE: False} - result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) - assert result is mock_clear_codegen["var"] - mock_clear_codegen["add"].assert_not_called() - - -@pytest.mark.asyncio -async def test_clear_action_with_save(mock_clear_codegen): - """With save=True, set_save(True) should be emitted exactly once.""" - config = {CONF_ID: MagicMock(), CONF_SAVE: True} - await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) - mock_clear_codegen["var"].set_save.assert_called_once_with(True) - mock_clear_codegen["add"].assert_called_once() - - # --- Calibration persistence invalidation --- # # The C++ side stores a hash of the build-time base matrix alongside the saved diff --git a/tests/component_tests/network/__init__.py b/tests/component_tests/network/__init__.py index e69de29bb2d..b4ed442ff8d 100644 --- a/tests/component_tests/network/__init__.py +++ b/tests/component_tests/network/__init__.py @@ -0,0 +1,11 @@ +"""Shared helpers for the network component tests.""" + +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.core import CORE + + +def sdkconfig_option(name: str) -> int | bool | None: + """Return a generated sdkconfig value, or None when unset or not ESP32.""" + if KEY_ESP32 not in CORE.data: + return None + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name) diff --git a/tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml b/tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml new file mode 100644 index 00000000000..31858c3fc5e --- /dev/null +++ b/tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: LAN8720 + mdc_pin: 23 + mdio_pin: 32 + clk: + pin: 0 + mode: CLK_EXT_IN + phy_addr: 0 + power_pin: 33 + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_ethernet_psram.yaml b/tests/component_tests/network/config/high_perf_ethernet_psram.yaml new file mode 100644 index 00000000000..3407fd5ffe8 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_ethernet_psram.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO14 + reset_pin: GPIO9 + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml b/tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml new file mode 100644 index 00000000000..b9b49c825db --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml @@ -0,0 +1,30 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO14 + reset_pin: GPIO9 + +network: + priority: + - wifi + - ethernet + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_wifi_no_psram.yaml b/tests/component_tests/network/config/high_perf_wifi_no_psram.yaml new file mode 100644 index 00000000000..c24e4b62539 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_no_psram.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_wifi_psram.yaml b/tests/component_tests/network/config/high_perf_wifi_psram.yaml new file mode 100644 index 00000000000..efdad282a44 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_psram.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/test_high_performance.py b/tests/component_tests/network/test_high_performance.py new file mode 100644 index 00000000000..c291806ac4b --- /dev/null +++ b/tests/component_tests/network/test_high_performance.py @@ -0,0 +1,54 @@ +"""Tests for the ``network: enable_high_performance:`` lwip tier selection. + +Ethernet drivers keep received frames in internal RAM, so an ethernet-only +build must keep lwip off the PSRAM tier even when PSRAM is guaranteed. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.network import require_high_performance_networking +from tests.component_tests.network import sdkconfig_option + + +@pytest.mark.parametrize( + ( + "fixture", + "window", + "tcp_mailbox", + "tcpip_mailbox", + "window_scale", + "wifi_rx_buffers", + ), + [ + ("high_perf_wifi_psram.yaml", 512000, 512, 512, True, 512), + ("high_perf_wifi_no_psram.yaml", 65534, 64, 64, None, 64), + # dual builds keep wifi's sizes; the ethernet component moves frames to PSRAM + ("high_perf_wifi_ethernet_psram.yaml", 512000, 512, 512, True, 512), + ("high_perf_ethernet_no_psram.yaml", 16384, 64, 32, None, None), + # ethernet only with PSRAM guaranteed: the cell the guard exists for + ("high_perf_ethernet_psram.yaml", 16384, 64, 32, None, None), + ], +) +def test_lwip_tier( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + window: int, + tcp_mailbox: int, + tcpip_mailbox: int, + window_scale: bool | None, + wifi_rx_buffers: int | None, +) -> None: + # The wifi component only reacts to a component request, so request it the + # way sendspin does; the config key alone decides the lwip tier. + require_high_performance_networking() + generate_main(component_config_path(fixture)) + assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") == window + assert sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE") == tcp_mailbox + assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == tcpip_mailbox + assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale + # Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded + assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py index 041b358ddaf..57e412ea198 100644 --- a/tests/component_tests/network/test_priority.py +++ b/tests/component_tests/network/test_priority.py @@ -19,6 +19,7 @@ from esphome.components.network import ( from esphome.const import CONF_PRIORITY, PlatformFramework from esphome.core import CORE import esphome.final_validate as fv +from tests.component_tests.network import sdkconfig_option from tests.component_tests.types import SetCoreConfigCallable @@ -217,13 +218,7 @@ def test_no_primary_interface_define_without_priority( def _dns_per_default_netif_option() -> bool | None: - from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS - - if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all - return None - return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get( - "CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF" - ) + return sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF") @pytest.mark.parametrize( diff --git a/tests/component_tests/network/test_tcp_send_buffer.py b/tests/component_tests/network/test_tcp_send_buffer.py index 64989212439..bf68c7c11cd 100644 --- a/tests/component_tests/network/test_tcp_send_buffer.py +++ b/tests/component_tests/network/test_tcp_send_buffer.py @@ -14,32 +14,24 @@ import pytest from voluptuous import Invalid from esphome import config_validation as cv -from esphome.components.esp32.const import ( - KEY_SDKCONFIG_OPTIONS, - KEY_VARIANT, - VARIANT_ESP32, -) +from esphome.components.esp32.const import KEY_VARIANT, VARIANT_ESP32 from esphome.components.network import ( CONF_TCP_SEND_BUFFER, CONFIG_SCHEMA, TCP_SEND_BUFFER_MAX, TCP_SEND_BUFFER_MIN, ) -from esphome.const import KEY_ESP32, KEY_FRAMEWORK_VERSION, PlatformFramework -from esphome.core import CORE +from esphome.const import KEY_FRAMEWORK_VERSION, PlatformFramework +from tests.component_tests.network import sdkconfig_option from tests.component_tests.types import SetCoreConfigCallable -def _sdkconfig_option(name: str) -> int | None: - return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name) - - def test_tcp_send_buffer_sets_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: generate_main(component_config_path("tcp_send_buffer.yaml")) - assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000 + assert sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000 def test_tcp_send_buffer_overrides_high_performance( @@ -48,7 +40,7 @@ def test_tcp_send_buffer_overrides_high_performance( ) -> None: """An explicit size wins over the high performance bundle's 65534.""" generate_main(component_config_path("tcp_send_buffer_high_perf.yaml")) - assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384 + assert sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384 @pytest.mark.parametrize("value", [TCP_SEND_BUFFER_MIN, TCP_SEND_BUFFER_MAX]) diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 00000000000..b3eae34436f --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 00000000000..b33508602af --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc1..cd66503c1bb 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -12,10 +12,11 @@ from esphome import config_validation as cv from esphome.components.esphome.ota import ( AUTO_LOAD, FILTER_SOURCE_FILES, + _encryption_schema, _validate_no_password_with_encryption, ota_esphome_final_validate, ) -from esphome.components.noise import static_encryption_key +from esphome.components.noise import encryption_schema, static_encryption_key from esphome.const import ( CONF_API, CONF_ENCRYPTION, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv @@ -198,6 +200,21 @@ def test_encryption_without_any_key_rejected() -> None: fv.full_config.reset(token) +def test_encryption_schema_allow_plaintext_upload() -> None: + """The opt in is an ota only option with no default, so a merged block + that does not mention it cannot clear it; the shared api schema does not + know it.""" + assert _encryption_schema(None) == {} + assert _encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) == { + CONF_ALLOW_PLAINTEXT_UPLOAD: True + } + assert _encryption_schema({CONF_KEY: API_KEY}) == {CONF_KEY: API_KEY} + with pytest.raises(cv.Invalid): + _encryption_schema(False) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -319,6 +336,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + 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( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + 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.""" @@ -450,43 +493,43 @@ def test_static_encryption_key() -> None: ("yaml_name", "defines_present", "defines_absent"), [ # An api key alone compiles the transport in without requiring it; - # the device uses the api server's key, not a copy + # the ota keeps its own pointer to the key so safe mode, which never + # constructs the api server, can still use it ( "api_key_offer", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"}, + {"USE_OTA_ENCRYPTION"}, {"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", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), + # The migration install keeps the password for the old firmware's + # prompt but the build it sends is authenticated by the key alone + ( + "migration_password", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, + {"USE_OTA_PASSWORD", "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", "USE_OTA_ENCRYPTION_REQUIRED"}, {"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"}, + {"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", "USE_OTA_ENCRYPTION_PROVISIONED"}, {"USE_OTA_ENCRYPTION_REQUIRED"}, ), # No api encryption at all keeps the noise glue out of the build @@ -496,7 +539,6 @@ def test_static_encryption_key() -> None: { "USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED", }, ), @@ -515,8 +557,10 @@ def test_encryption_offer_codegen( 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 + own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key + # The api shares the ota's array instead of emitting the same key twice + assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0) 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"]) @@ -529,6 +573,16 @@ def test_password_with_encryption_rejected() -> None: _validate_no_password_with_encryption(config) +def test_password_with_migration_install_accepted() -> None: + """The old firmware may still ask for the password on the plaintext + leg of the migration install.""" + config = { + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_ALLOW_PLAINTEXT_UPLOAD: True}, + } + assert _validate_no_password_with_encryption(config) is config + + def test_password_alone_accepted() -> None: """A password without encryption still validates.""" config = {CONF_PASSWORD: "pw"} diff --git a/tests/component_tests/ota/test_esphome_ota_migration_password.yaml b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml new file mode 100644 index 00000000000..6a44f75751b --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml @@ -0,0 +1,14 @@ +esphome: + name: ota-migration-password + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 00000000000..84c5eafc5ab --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 00000000000..682ae9de511 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 00000000000..172715aef08 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/component_tests/pzem6l24/__init__.py b/tests/component_tests/pzem6l24/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/pzem6l24/test_address.py b/tests/component_tests/pzem6l24/test_address.py new file mode 100644 index 00000000000..a9e14361c0d --- /dev/null +++ b/tests/component_tests/pzem6l24/test_address.py @@ -0,0 +1,28 @@ +"""The PZEM-6L24 answers unit addresses 1 to 247 only.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.pzem6l24.sensor import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _sensor(**extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", **extra}) + + +def test_address_defaults_to_one() -> None: + assert _sensor()[CONF_ADDRESS] == 1 + + +@pytest.mark.parametrize("address", [1, 247]) +def test_address_in_unit_range_accepted(address: int) -> None: + assert _sensor(**{CONF_ADDRESS: address})[CONF_ADDRESS] == address + + +@pytest.mark.parametrize("address", [0, 248, 255]) +def test_address_outside_unit_range_rejected(address: int) -> None: + with pytest.raises(cv.Invalid): + _sensor(**{CONF_ADDRESS: address}) diff --git a/tests/component_tests/remote_receiver/__init__.py b/tests/component_tests/remote_receiver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/remote_receiver/config/receiver_bare.yaml b/tests/component_tests/remote_receiver/config/receiver_bare.yaml new file mode 100644 index 00000000000..b4741948015 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bare.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml new file mode 100644 index 00000000000..c9c95ed05c3 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +bk72xx: + board: generic-bk7252 + +remote_receiver: + - id: rcvr + pin: P6 diff --git a/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml b/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml new file mode 100644 index 00000000000..0b334954ebb --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr + pin: GPIO4 + buffer_size: 2kb diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml new file mode 100644 index 00000000000..c4497fefd87 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c2-devkitm-1 + variant: esp32c2 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml new file mode 100644 index 00000000000..e8930d4e17e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c61-devkitc1 + variant: esp32c61 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml b/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml new file mode 100644 index 00000000000..f22d00d630f --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml new file mode 100644 index 00000000000..8767b546e62 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +ln882x: + board: generic-ln882h + +remote_receiver: + - id: rcvr + pin: PA4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rp2.yaml b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml new file mode 100644 index 00000000000..cfc66786ba2 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rp2: + board: rpipicow + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml new file mode 100644 index 00000000000..113bece34c5 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +remote_receiver: + - id: rcvr + pin: PA12 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml b/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml new file mode 100644 index 00000000000..32c1b07f579 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - nec + - rc_switch + on_nec: + then: + - logger.log: nec + +binary_sensor: + - platform: remote_receiver + name: Remote Input + nec: + address: 0x1234 + command: 0x5678 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml new file mode 100644 index 00000000000..e094e5bd52e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml @@ -0,0 +1,36 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +external_components: + - source: + type: local + path: ../external_components + +fake_protocol: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - fake + - nec + on_fake: + then: + - remote_transmitter.transmit_fake: + on_nec: + then: + - logger.log: nec + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +binary_sensor: + - platform: remote_receiver + name: Fake Input + fake: diff --git a/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml b/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml new file mode 100644 index 00000000000..c443a842f23 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr_ir + pin: GPIO4 + - id: rcvr_rf + pin: GPIO5 + +infrared: + - platform: ir_rf_proxy + name: IR Receiver + remote_receiver_id: rcvr_ir + +radio_frequency: + - platform: ir_rf_proxy + name: RF Receiver + frequency: 433.92MHz + remote_receiver_id: rcvr_rf diff --git a/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py new file mode 100644 index 00000000000..971497aa794 --- /dev/null +++ b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py @@ -0,0 +1,39 @@ +"""External component registering a protocol that has no source file in remote_base.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.types import ConfigType + +DEPENDENCIES = ["remote_base"] + +ns = cg.esphome_ns.namespace("fake_protocol") +FakeData = ns.struct("FakeData") +FakeBinarySensor = ns.class_( + "FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase +) +FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger) +FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase) +FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase) + +CONFIG_SCHEMA = cv.Schema({}) + + +@remote_base.register_binary_sensor("fake", FakeBinarySensor, {}) +def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_trigger("fake", FakeTrigger, FakeData) +def fake_trigger(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_dumper("fake", FakeDumper) +def fake_dumper(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_action("fake", FakeAction, {}) +async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None: + pass diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py new file mode 100644 index 00000000000..cc4ea49ccb8 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -0,0 +1,48 @@ +"""buffer_size is bytes on the pulse ring targets and only reaches RMT targets when set.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import remote_receiver +from esphome.components.esp8266 import gpio as esp8266_gpio # noqa: F401 registers the pin schema +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_explicit_buffer_size_is_passed_through( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_buffer_size.yaml")) + assert "rcvr->set_buffer_size(2000);" in main_cpp + + +@pytest.mark.parametrize( + "target", ["esp8266", "rp2", "bk72xx", "rtl87xx", "ln882x", "esp32_c2", "esp32_c61"] +) +def test_pulse_ring_default_holds_1000_pulses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + target: str, +) -> None: + main_cpp = generate_main(component_config_path(f"receiver_{target}.yaml")) + assert "rcvr->set_buffer_size(4000);" in main_cpp + + +@pytest.mark.parametrize( + ("value", "expected"), + [("32b", None), ("64b", 64), ("65b", 65), ("65535b", 65535), ("65536b", None)], +) +def test_buffer_size_range( + set_core_config: SetCoreConfigCallable, value: str, expected: int | None +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + config = {"pin": "GPIO4", "buffer_size": value} + if expected is None: + with pytest.raises(Invalid): + remote_receiver.CONFIG_SCHEMA(config) + else: + assert remote_receiver.CONFIG_SCHEMA(config)["buffer_size"] == expected diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py new file mode 100644 index 00000000000..ee79e9a06d5 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -0,0 +1,137 @@ +"""Listener and dumper StaticVector sizes come from codegen slot counts.""" + +from collections.abc import Callable, Generator +from pathlib import Path +import sys + +import pytest + +from esphome import loader +from esphome.automation import ACTION_REGISTRY +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.core import CORE + +from ..helpers import get_define_value + + +def test_dumper_and_listener_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_dumpers.yaml")) + # nec and rc_switch dumpers + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + # on_nec trigger plus the remote_receiver binary sensor + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "2" + + +def test_bare_receiver_emits_no_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_bare.yaml")) + # the RMT ring is sized in setup() unless buffer_size is set + assert "set_buffer_size" not in main_cpp + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") is None + + +def test_proxy_receivers_count_as_listeners( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_proxies.yaml")) + # one proxy entity listens on each of the two receivers; every receiver's list gets the + # capacity of the busiest one, so this is the largest per receiver count, not the sum + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "1" + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None + + +def test_only_used_protocol_sources_are_compiled( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_dumpers.yaml")) + excluded = set(remote_base.FILTER_SOURCE_FILES()) + assert "nec_protocol.cpp" not in excluded + assert "rc_switch_protocol.cpp" not in excluded + assert "sony_protocol.cpp" in excluded + assert "remote_base.cpp" not in excluded + + +def test_every_registry_name_maps_to_a_protocol_source() -> None: + """A registry name must resolve to a source file or request_protocol rejects it.""" + names = ( + set(remote_base.BINARY_SENSOR_REGISTRY) + | set(remote_base.DUMPER_REGISTRY) + | {key.removeprefix("on_") for key in remote_base.TRIGGER_REGISTRY} + | { + key.removeprefix("remote_transmitter.transmit_") + for key in ACTION_REGISTRY + if key.startswith("remote_transmitter.transmit_") + } + ) + assert len(names) > 40 + for name in names: + assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name + + +@pytest.fixture +def restore_protocol_registries() -> Generator[None]: + """Loading an external protocol component adds to module-level registries; undo that. + + The loader caches the component too, so drop it or a second load would skip the + decorators and leave the restored registries without the external names. + """ + registries = ( + remote_base.BINARY_SENSOR_REGISTRY, + remote_base.TRIGGER_REGISTRY, + remote_base.DUMPER_REGISTRY, + ACTION_REGISTRY, + ) + saved = [dict(registry) for registry in registries] + yield + for registry, entries in zip(registries, saved, strict=True): + registry.clear() + registry.update(entries) + loader._COMPONENT_CACHE.pop("fake_protocol", None) + sys.modules.pop("esphome.components.fake_protocol", None) + + +@pytest.mark.usefixtures("restore_protocol_registries") +def test_external_protocols_register_without_a_remote_base_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An external protocol goes through all four decorators without a source file here, so no define is emitted.""" + main_cpp = generate_main( + component_config_path("receiver_with_external_protocol.yaml") + ) + defines = {define.name for define in CORE.defines} + assert "USE_REMOTE_PROTOCOL_NEC" in defines + assert "USE_REMOTE_PROTOCOL_FAKE" not in defines + for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"): + assert f"fake_protocol::{cls}" in main_cpp, cls + # fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3" + + +def test_request_protocol_rejects_unknown_names() -> None: + """A misspelled protocol would otherwise surface only as a link error.""" + with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): + remote_base.request_protocol("toshiba") + + +def test_dump_list_is_deduplicated_across_forms() -> None: + dumpers = remote_base.validate_dumpers(["raw", {"raw": None}, "nec", "nec"]) + assert [ + next(k for k in entry if k in remote_base.DUMPER_REGISTRY) for entry in dumpers + ] == ["raw", "nec"] + + +@pytest.mark.parametrize("bad", [["nec", None], [5]]) +def test_dump_list_rejects_invalid_entries_with_a_validation_error(bad: list) -> None: + with pytest.raises(cv.Invalid): + remote_base.validate_dumpers(bad) diff --git a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py index fdcf2bcf757..1106fd01eb9 100644 --- a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py @@ -37,12 +37,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp diff --git a/tests/component_tests/sendspin/config/device_info_default.yaml b/tests/component_tests/sendspin/config/device_info_default.yaml new file mode 100644 index 00000000000..669b2e99bc6 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_default.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: diff --git a/tests/component_tests/sendspin/config/device_info_explicit.yaml b/tests/component_tests/sendspin/config/device_info_explicit.yaml new file mode 100644 index 00000000000..c3fec3ead45 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_explicit.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + project: + name: project_manufacturer.project_model + version: 9.9.9 + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: + manufacturer: Explicit Manufacturer + model: Explicit Model + firmware_version: 1.2.3 diff --git a/tests/component_tests/sendspin/config/device_info_project.yaml b/tests/component_tests/sendspin/config/device_info_project.yaml new file mode 100644 index 00000000000..395b2889fc3 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_project.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + project: + name: project_manufacturer.project_model + version: 9.9.9 + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: diff --git a/tests/component_tests/sendspin/test_device_info.py b/tests/component_tests/sendspin/test_device_info.py new file mode 100644 index 00000000000..61c10676dad --- /dev/null +++ b/tests/component_tests/sendspin/test_device_info.py @@ -0,0 +1,80 @@ +"""Tests for the device information the sendspin hub reports to the server.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.const import CONF_MANUFACTURER +from esphome.components.sendspin import CONF_FIRMWARE_VERSION, CONFIG_SCHEMA +from esphome.const import CONF_MODEL, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_explicit_device_info_wins_over_project( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Configured values take precedence over the project information.""" + main_cpp = generate_main(component_config_path("device_info_explicit.yaml")) + + assert 'set_manufacturer("Explicit Manufacturer")' in main_cpp + assert 'set_model("Explicit Model")' in main_cpp + assert 'set_firmware_version("1.2.3")' in main_cpp + + +def test_project_supplies_device_info( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without configured values, the project name splits into manufacturer and model.""" + main_cpp = generate_main(component_config_path("device_info_project.yaml")) + + assert 'set_manufacturer("project_manufacturer")' in main_cpp + assert 'set_model("project_model")' in main_cpp + assert 'set_firmware_version("9.9.9")' in main_cpp + + +def test_no_device_info_leaves_hub_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With neither source, nothing is emitted and the hub keeps its own defaults.""" + main_cpp = generate_main(component_config_path("device_info_default.yaml")) + + assert "set_manufacturer(" not in main_cpp + assert "set_model(" not in main_cpp + assert "set_firmware_version(" not in main_cpp + + +@pytest.mark.parametrize( + "conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION] +) +def test_empty_device_info_rejected( + set_core_config: SetCoreConfigCallable, conf_key: str +) -> None: + """An empty string would be sent to the server as an empty value, so it is not accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: ""}) + + +@pytest.mark.parametrize( + "conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION] +) +def test_device_info_capped_at_127_bytes( + set_core_config: SetCoreConfigCallable, conf_key: str +) -> None: + """The cap is in bytes so the protobuf length prefix stays a single byte.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA({conf_key: "a" * 127}) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: "a" * 128}) + # 64 two-byte characters is 128 bytes. + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: "é" * 64}) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py new file mode 100644 index 00000000000..6c2f79198dd --- /dev/null +++ b/tests/component_tests/sendspin/test_media_source.py @@ -0,0 +1,90 @@ +"""Validation tests for the sendspin media_source platform. + +These cover the codec preference list, whose rejection branches a compile test +cannot reach: a `test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_CODECS, _get_data +from esphome.components.sendspin.media_source import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _media_source_config(**overrides: Any) -> ConfigType: + """Build a minimal valid media source config, allowing field overrides.""" + config: ConfigType = { + "id": "sendspin_media_source", + "sendspin_id": "sendspin_hub", + } + config.update(overrides) + return config + + +def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None: + """Every codec is advertised when the sample rate suits all of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config()) + + assert config[CONF_CODECS] == ["flac", "opus", "pcm"] + + +def test_default_codecs_drop_opus_at_other_rates( + set_core_config: SetCoreConfigCallable, +) -> None: + """Opus only supports 48 kHz, so it leaves the default list at other rates.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100)) + + assert config[CONF_CODECS] == ["flac", "pcm"] + + +def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None: + """The list is a preference order, so it reaches the player role as written.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"])) + + assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"] + + +def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A player with no codecs at all could never be given a stream.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="length of value must be at least 1"): + CONFIG_SCHEMA(_media_source_config(codecs=[])) + + +def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A repeated codec has no meaning in a preference order.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="may only be listed once"): + CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"])) + + +def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Only codecs the player role can decode are accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA(_media_source_config(codecs=["mp3"])) + + +def test_opus_at_wrong_sample_rate_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Asking for Opus at a rate it cannot handle fails rather than silently + dropping the stated preference.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) diff --git a/tests/component_tests/template/config/switch_defaults.yaml b/tests/component_tests/template/config/switch_defaults.yaml new file mode 100644 index 00000000000..4387fe07a5b --- /dev/null +++ b/tests/component_tests/template/config/switch_defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +switch: + - platform: template + id: plain_switch + turn_on_action: + - logger.log: "on" + - platform: template + id: enabled_switch + optimistic: true + assumed_state: true diff --git a/tests/component_tests/template/test_template_switch.py b/tests/component_tests/template/test_template_switch.py new file mode 100644 index 00000000000..11c6a9cab87 --- /dev/null +++ b/tests/component_tests/template/test_template_switch.py @@ -0,0 +1,17 @@ +"""Tests for the template switch codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flags_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only true optimistic and assumed_state are set; false is the C++ initializer.""" + main_cpp = generate_main(component_config_path("switch_defaults.yaml")) + + assert "plain_switch->set_optimistic(" not in main_cpp + assert "plain_switch->set_assumed_state(" not in main_cpp + assert "enabled_switch->set_optimistic(true);" in main_cpp + assert "enabled_switch->set_assumed_state(true);" in main_cpp diff --git a/tests/component_tests/template/test_template_visibility.py b/tests/component_tests/template/test_template_visibility.py new file mode 100644 index 00000000000..a50a27e1f7b --- /dev/null +++ b/tests/component_tests/template/test_template_visibility.py @@ -0,0 +1,76 @@ +"""The template platforms surface value-describing metadata on the main form. + +Hardware platforms get sensible defaults for unit/device_class/etc., so those +fields fall through to the editor's advanced disclosure. A ``template`` entity +has no such defaults -- the user is expected to define them -- so the template +platforms pass ``visibility=cv.Visibility.UI`` to promote them onto the form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +@pytest.mark.parametrize( + ("platform", "fields"), + [ + ( + "sensor", + [ + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ], + ), + ("binary_sensor", ["device_class"]), + ("switch", ["device_class"]), + ("cover", ["device_class"]), + ("button", ["device_class"]), + ("valve", ["device_class"]), + ("event", ["device_class"]), + ("text_sensor", ["device_class"]), + ("number", ["device_class", "unit_of_measurement"]), + ], +) +def test_template_metadata_is_ui(platform: str, fields: list[str]) -> None: + mod = importlib.import_module(f"esphome.components.template.{platform}") + markers = _markers(mod.CONFIG_SCHEMA) + for field in fields: + assert markers[field].visibility is cv.Visibility.UI, f"{platform}.{field}" + + +def test_template_sensor_promotion_preserves_defaults() -> None: + """Promoting to UI must not drop the fields' defaults.""" + from esphome.components.template.sensor import CONFIG_SCHEMA + + markers = _markers(CONFIG_SCHEMA) + assert markers["accuracy_decimals"].default() == 1 + assert markers["force_update"].default() is False + + +def test_hardware_platform_metadata_not_promoted() -> None: + """Without ``visibility=`` the builders leave metadata unset. + + Unset markers fall through to the consumer's ``Optional`` default of + advanced, so hardware platforms are unaffected by the template promotion. + """ + from esphome.components import binary_sensor, sensor + + hw_sensor = _markers(sensor.sensor_schema(device_class="temperature")) + assert hw_sensor["device_class"].visibility is None + hw_bs = _markers(binary_sensor.binary_sensor_schema(device_class="motion")) + assert hw_bs["device_class"].visibility is None diff --git a/tests/component_tests/types.py b/tests/component_tests/types.py index ee9d3173398..3587517bde0 100644 --- a/tests/component_tests/types.py +++ b/tests/component_tests/types.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Protocol +from esphome.config import Config from esphome.const import PlatformFramework from esphome.types import ConfigType @@ -18,5 +19,5 @@ class SetCoreConfigCallable(Protocol): *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, - full_config: dict[str, ConfigType] | None = None, + full_config: dict[str, ConfigType] | Config | None = None, ) -> None: ... diff --git a/tests/component_tests/uart/test_clock_source.py b/tests/component_tests/uart/test_clock_source.py new file mode 100644 index 00000000000..3709b593fbe --- /dev/null +++ b/tests/component_tests/uart/test_clock_source.py @@ -0,0 +1,112 @@ +"""Tests for UART clock source validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import KEY_VARIANT, VARIANTS +from esphome.components.uart import ( + CONF_CLOCK_SOURCE, + CONFIG_SCHEMA, + UART_CLOCK_SOURCES, + UART_CLOCK_SOURCES_BY_VARIANT, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def clock_source_validator() -> cv.All: + """Find the clock validator without depending on pre-validator ordering.""" + schema = next( + validator + for validator in CONFIG_SCHEMA.validators + if isinstance(validator, cv.Schema) + ) + return schema.schema[cv.Optional(CONF_CLOCK_SOURCE)] + + +def test_clock_source_table_covers_all_variants() -> None: + """New ESP32 variants must declare their supported UART clocks.""" + assert set(UART_CLOCK_SOURCES_BY_VARIANT) == set(VARIANTS) + for sources in UART_CLOCK_SOURCES_BY_VARIANT.values(): + assert "DEFAULT" in sources + assert set(sources) <= UART_CLOCK_SOURCES.keys() + + +@pytest.mark.parametrize( + ("variant", "source", "supported"), + [ + ("ESP32", "REF_TICK", True), + ("ESP32", "XTAL", False), + ("ESP32S2", "REF_TICK", True), + ("ESP32S2", "RTC", False), + ("ESP32C3", "APB", True), + ("ESP32C3", "xtal", True), + ("ESP32C3", "REF_TICK", False), + ("ESP32S3", "RTC", True), + ("ESP32S3", "REF_TICK", False), + ("ESP32C2", "APB", False), + ("ESP32C6", "APB", False), + ("ESP32C6", "DEFAULT", True), + ("ESP32C5", "RTC", True), + ("ESP32C61", "XTAL", True), + ("ESP32H4", "RTC", True), + ("ESP32H21", "XTAL", True), + ("ESP32S31", "RTC", True), + ("ESP32H2", "XTAL", True), + ("ESP32P4", "RTC", True), + ("ESP32C3", "INVALID", False), + ], +) +def test_clock_source_validation( + variant: str, + source: str, + supported: bool, + set_core_config: SetCoreConfigCallable, + clock_source_validator: cv.All, +) -> None: + """Reject unsupported clocks and normalize accepted names.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + ) + if supported: + assert clock_source_validator(source) == source.upper() + else: + with pytest.raises(cv.Invalid): + clock_source_validator(source) + + +def test_clock_source_requires_esp32( + set_core_config: SetCoreConfigCallable, + clock_source_validator: cv.All, +) -> None: + """Reject the ESP32-only option before accessing the chip variant.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP32"): + clock_source_validator("DEFAULT") + + +@pytest.mark.parametrize("source", [None, "default", "xtal"]) +def test_clock_source_codegen( + source: str | None, + tmp_path: Path, + generate_main: Callable[[str | Path], str], +) -> None: + """Emit IDF constants directly and skip the setter when nothing changes.""" + config = tmp_path / "uart.yaml" + config.write_text( + "esphome:\n name: uart-clock-test\n" + "esp32:\n variant: esp32c3\n framework:\n type: esp-idf\n" + "uart:\n id: test_uart\n tx_pin: GPIO4\n baud_rate: 9600\n" + + (f" clock_source: {source}\n" if source else ""), + encoding="utf-8", + ) + main_cpp = generate_main(config) + if source in (None, "default"): + assert "set_clock_source(" not in main_cpp + else: + assert f"test_uart->set_clock_source(::UART_SCLK_{source.upper()});" in main_cpp diff --git a/tests/component_tests/uart_mux/__init__.py b/tests/component_tests/uart_mux/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart_mux/test_init.py b/tests/component_tests/uart_mux/test_init.py new file mode 100644 index 00000000000..2221ec56694 --- /dev/null +++ b/tests/component_tests/uart_mux/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the uart_mux component's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_BRIDGE_ID = "bridge_id" + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3} + ) + + +def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType: + return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)} + + +def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + uart_mux._final_validate(_mux_conf("mux_1", "bridge_1")) + + +def test_rejects_two_muxes_on_one_bridge( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"): + uart_mux._final_validate(_mux_conf("mux_1", "bridge_0")) diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 00000000000..dae1c488832 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 00000000000..2d37d7ae19d --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 00000000000..3c34da43ac1 --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 00000000000..2b13ed966b5 --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 00000000000..94e5de47a0f --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 00000000000..068479a5404 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 00000000000..1b5e7d7dba9 --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 00000000000..b326f3eaeeb --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 00000000000..2b4200968a7 --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/component_tests/zephyr_ble_server/__init__.py b/tests/component_tests/zephyr_ble_server/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_ble_server/config/no_request.yaml b/tests/component_tests/zephyr_ble_server/config/no_request.yaml new file mode 100644 index 00000000000..dcf79033eca --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/no_request.yaml @@ -0,0 +1,8 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +zephyr_ble_server: diff --git a/tests/component_tests/zephyr_ble_server/config/nus.yaml b/tests/component_tests/zephyr_ble_server/config/nus.yaml new file mode 100644 index 00000000000..63129069d26 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus.yaml @@ -0,0 +1,11 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +ble_nus: + type: logs diff --git a/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml new file mode 100644 index 00000000000..d62247dc546 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml @@ -0,0 +1,18 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ble_nus: + type: logs + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py new file mode 100644 index 00000000000..af0c4112b03 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py @@ -0,0 +1,52 @@ +"""Tests for the BLE L2CAP MTU that zephyr_ble_server writes to prj.conf.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_PRJ_CONF +from esphome.components.zephyr_ble_server import _emit_ble_mtu, request_ble_l2cap_mtu + +MTU_KEYS = ( + "CONFIG_BT_L2CAP_TX_MTU", + "CONFIG_BT_BUF_ACL_TX_SIZE", + "CONFIG_BT_BUF_ACL_RX_SIZE", +) + + +def _mtu_values() -> tuple[int | None, ...]: + prj_conf = zephyr_data()[KEY_PRJ_CONF][""] + return tuple(prj_conf.get(key, (None,))[0] for key in MTU_KEYS) + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("no_request.yaml", (None, None, None)), + ("nus.yaml", (247, 251, 251)), + # The largest request wins; only the TX buffer is capped at 251 + ("nus_mcumgr.yaml", (498, 251, 502)), + ], +) +def test_l2cap_mtu_prj_conf( + fixture: str, + expected: tuple[int | None, ...], + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + assert _mtu_values() == expected + + +@pytest.mark.asyncio +async def test_request_after_emit_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("no_request.yaml")) + request_ble_l2cap_mtu(247) + await _emit_ble_mtu() + with pytest.raises(RuntimeError, match="after it was written"): + request_ble_l2cap_mtu(498) diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a9..8fa62c0636b 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% diff --git a/tests/components/ags10/common-actions.yaml b/tests/components/ags10/common-actions.yaml new file mode 100644 index 00000000000..6c0e70115b1 --- /dev/null +++ b/tests/components/ags10/common-actions.yaml @@ -0,0 +1,19 @@ +button: + - platform: template + name: AGS10 Zero Point + on_press: + - ags10.set_zero_point: + id: ags10_1 + mode: FACTORY_DEFAULT + - ags10.set_zero_point: + id: ags10_1 + mode: CUSTOM_VALUE + value: 0x1234 + - ags10.set_zero_point: + id: ags10_1 + mode: !lambda return ags10::CURRENT_VALUE; + value: !lambda return 0x2345; + - ags10.new_i2c_address: 0x1B + - ags10.new_i2c_address: + id: ags10_1 + address: !lambda return 0x1C; diff --git a/tests/components/ags10/test-actions.esp8266-ard.yaml b/tests/components/ags10/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..2fb84b12f98 --- /dev/null +++ b/tests/components/ags10/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c_low_freq: !include ../../test_build_components/common/i2c_low_freq/esp8266-ard.yaml + ags10: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/aic3204/common-actions.yaml b/tests/components/aic3204/common-actions.yaml new file mode 100644 index 00000000000..7981fcc1b07 --- /dev/null +++ b/tests/components/aic3204/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: AIC3204 Auto Mute + on_press: + - aic3204.set_auto_mute_mode: 3 + - aic3204.set_auto_mute_mode: + id: aic3204_dac + mode: !lambda return 7; diff --git a/tests/components/aic3204/common.yaml b/tests/components/aic3204/common.yaml index 5f175faee38..8cb10b24f8a 100644 --- a/tests/components/aic3204/common.yaml +++ b/tests/components/aic3204/common.yaml @@ -5,7 +5,10 @@ esphome: - audio_dac.mute_on: - audio_dac.set_volume: volume: 50% + - audio_dac.set_volume: + volume: !lambda return id(aic3204_dac).volume() * 0.5f; audio_dac: - platform: aic3204 + id: aic3204_dac i2c_id: i2c_bus diff --git a/tests/components/aic3204/test-actions.esp8266-ard.yaml b/tests/components/aic3204/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c5431f65a3e --- /dev/null +++ b/tests/components/aic3204/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + aic3204: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/alarm_control_panel/common-actions.yaml b/tests/components/alarm_control_panel/common-actions.yaml new file mode 100644 index 00000000000..d757fa631a3 --- /dev/null +++ b/tests/components/alarm_control_panel/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Alarm Calls + on_press: + - alarm_control_panel.pending: alarmcontrolpanel1 + - alarm_control_panel.triggered: alarmcontrolpanel1 + - if: + condition: + alarm_control_panel.is_armed: alarmcontrolpanel1 + then: + - logger.log: Armed + - if: + condition: + alarm_control_panel.ready: alarmcontrolpanel1 + then: + - logger.log: Ready diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 327234d6caa..f9c26ec5c9d 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -67,3 +67,28 @@ alarm_control_panel: on_cleared: then: - logger.log: "### CLEARED ###" + +button: + # Exercise the arm/disarm actions with a constant code, a lambda code and no code. + - platform: template + name: Alarm Panel Actions + on_press: + - alarm_control_panel.arm_away: + id: alarmcontrolpanel1 + code: "1234" + - alarm_control_panel.arm_home: + id: alarmcontrolpanel1 + code: !lambda |- + std::string code = "56"; + code += "78"; + return code; + - alarm_control_panel.arm_night: + id: alarmcontrolpanel1 + code: "0000" + - alarm_control_panel.disarm: + id: alarmcontrolpanel1 + code: "1234" + - alarm_control_panel.arm_away: alarmcontrolpanel2 + - alarm_control_panel.arm_home: alarmcontrolpanel2 + - alarm_control_panel.arm_night: alarmcontrolpanel2 + - alarm_control_panel.disarm: alarmcontrolpanel2 diff --git a/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml b/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..ce3259f91fc --- /dev/null +++ b/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + alarm_control_panel: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/animation/common-actions.yaml b/tests/components/animation/common-actions.yaml new file mode 100644 index 00000000000..45812374838 --- /dev/null +++ b/tests/components/animation/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: Animation Actions + on_press: + - animation.next_frame: rgb_animation + - animation.prev_frame: rgb_animation + - animation.set_frame: + id: rgb_animation + frame: 2 diff --git a/tests/components/animation/test-actions.esp8266-ard.yaml b/tests/components/animation/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..0d8a73f8bda --- /dev/null +++ b/tests/components/animation/test-actions.esp8266-ard.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + animation: !include common.yaml + actions: !include common-actions.yaml + +display: + - platform: ili9xxx + id: animation_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 5 + dc_pin: 15 + reset_pin: 16 + invert_colors: false diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py new file mode 100644 index 00000000000..a5e3731c751 --- /dev/null +++ b/tests/components/api/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # USE_API compiles every api source, so emit what they need. No socket + # override: an __init__.py there makes pytest import its conftest as socket.conftest. + async def to_code_testing(config): + cg.add_define("USE_API") + cg.add_define("USE_API_PLAINTEXT") + cg.add_define("API_MAX_SEND_QUEUE", 8) + cg.add_define("MAX_API_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + + manifest.to_code = to_code_testing diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 5e3139da48c..f55e7b176dd 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -92,7 +92,7 @@ api: then: - logger.log: # yamllint disable rule:line-length - format: "Bool: %s (%u), Int: %ld (%u), Float: %f (%u), String: %s (%u)" + format: "Bool: %s (%zu), Int: %ld (%zu), Float: %f (%zu), String: %s (%zu)" # yamllint enable rule:line-length args: - YESNO(bool_arr[0]) @@ -116,7 +116,7 @@ api: then: - delay: 20ms - logger.log: - format: "Delayed: %s (%u ints, %u strings)" + format: "Delayed: %s (%zu ints, %zu strings)" args: - name.c_str() - int_arr.size() @@ -139,7 +139,7 @@ api: message: !lambda 'return name;' on_success: - logger.log: - format: "Notified %s (%u ints)" + format: "Notified %s (%zu ints)" args: - name.c_str() - int_arr.size() diff --git a/tests/components/api/test_api_buffer.cpp b/tests/components/api/test_api_buffer.cpp new file mode 100644 index 00000000000..c54780050e3 --- /dev/null +++ b/tests/components/api/test_api_buffer.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::testing { + +// Pointer plus two 16 bit sizes +static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *)); + +TEST(APIBuffer, RefusesSizesAbove16Bits) { + APIBuffer buf; + ASSERT_TRUE(buf.resize(16)); + EXPECT_FALSE(buf.reserve(UINT16_MAX + 1)); + EXPECT_EQ(buf.size(), 16u); + EXPECT_EQ(buf.capacity(), 16u); + EXPECT_TRUE(buf.reserve(UINT16_MAX)); + EXPECT_EQ(buf.capacity(), UINT16_MAX); +} + +static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6}; + +TEST(APIBuffer, AppendReturnsTheNewBytes) { + APIBuffer buf; + ASSERT_TRUE(buf.reserve(8)); + uint8_t *first = buf.append(3); + ASSERT_NE(first, nullptr); + std::memcpy(first, BYTES, 3); + EXPECT_EQ(buf.size(), 3u); + EXPECT_EQ(buf.capacity(), 8u); + + // Grows through realloc and keeps what was there + uint8_t *second = buf.append(6); + ASSERT_EQ(second, buf.data() + 3); + std::memcpy(second, BYTES + 3, 3); + EXPECT_EQ(buf.size(), 9u); + EXPECT_EQ(buf.capacity(), 9u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0); +} + +TEST(APIBuffer, DropFrontSlidesTheRestDown) { + APIBuffer buf; + uint8_t *bytes = buf.append(6); + ASSERT_NE(bytes, nullptr); + std::memcpy(bytes, BYTES, 6); + + buf.drop_front(2); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(buf.capacity(), 6u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Growing afterwards keeps the slid bytes + ASSERT_TRUE(buf.reserve(64)); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Dropping everything leaves an empty buffer with its capacity + buf.drop_front(4); + EXPECT_EQ(buf.size(), 0u); + EXPECT_EQ(buf.capacity(), 64u); +} + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_overflow_buffer.cpp b/tests/components/api/test_overflow_buffer.cpp new file mode 100644 index 00000000000..4b27e544963 --- /dev/null +++ b/tests/components/api/test_overflow_buffer.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_overflow_buffer.h" + +#ifdef USE_HOST +namespace esphome::api::testing { + +// Idle cost is the buffer plus one word of bookkeeping +static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *)); + +// Exposes storage so tests can check it is reused, not reallocated +class TestOverflowBuffer : public APIOverflowBuffer { + public: + using APIOverflowBuffer::LEN_PREFIX; + using APIOverflowBuffer::MAX_BYTES; + using APIOverflowBuffer::MAX_LONE_BYTES; + struct Storage { + size_t capacity; + const uint8_t *data; + bool operator==(const Storage &) const = default; + }; + size_t capacity() const { return this->buf_.capacity(); } + Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; } + uint8_t count() const { return this->count_; } + size_t live() const { return this->buf_.size() - this->head_; } + /// Simulates a socket write inside try_drain() re-entering the send path + void set_draining(bool draining) { this->draining_ = draining; } +}; + +static std::vector make_message(size_t len, uint8_t seed) { + std::vector msg(len); + for (size_t i = 0; i < len; i++) + msg[i] = static_cast(seed + i); + return msg; +} + +static bool enqueue(TestOverflowBuffer &buf, const std::vector &msg, uint16_t skip = 0) { + struct iovec iov = {const_cast(msg.data()), msg.size()}; + return buf.enqueue_iov(&iov, 1, static_cast(msg.size()), skip); +} + +static void append(std::vector &dst, const std::vector &src, size_t skip = 0) { + dst.insert(dst.end(), src.begin() + skip, src.end()); +} + +static std::vector concat(std::initializer_list> parts) { + std::vector out; + for (const auto &part : parts) + append(out, part); + return out; +} + +/// The pipe delivers the filler first, then the drained messages. +static void expect_after_filler(const std::vector &received, size_t filler, + const std::vector &expected) { + ASSERT_EQ(received.size(), filler + expected.size()); + EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler)); +} + +// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection +class OverflowBufferTest : public ::testing::Test { + protected: + void SetUp() override { + int fds[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + int size = 4096; + ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0); + this->reader_ = fds[1]; + this->sock_ = std::make_unique(fds[0]); + ASSERT_EQ(this->sock_->setblocking(false), 0); + } + void TearDown() override { ::close(this->reader_); } + + /// Write filler until the socket refuses; returns the bytes accepted + size_t fill_pipe_() { + uint8_t junk[512]; + std::memset(junk, 0xEE, sizeof(junk)); + size_t total = 0; + for (;;) { + ssize_t written = this->sock_->write(junk, sizeof(junk)); + if (written <= 0) + break; + total += static_cast(written); + } + return total; + } + + /// Append whatever the pipe currently holds. + void read_into_(std::vector &out) { + uint8_t tmp[1024]; + for (;;) { + ssize_t n = ::read(this->reader_, tmp, sizeof(tmp)); + if (n <= 0) + break; + out.insert(out.end(), tmp, tmp + n); + } + } + + /// Drain once; a refusal must be a would-block, never a hard error. + ssize_t drain_(TestOverflowBuffer &buf) { + ssize_t sent = buf.try_drain(this->sock_.get()); + if (sent == -1) { + EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN); + } + return sent; + } + + /// Read and drain until the backlog is empty; returns all bytes received + std::vector drain_all_(TestOverflowBuffer &buf) { + std::vector received; + for (int i = 0; i < 10000 && !buf.empty(); i++) { + this->read_into_(received); + // A hard socket error would never clear the backlog; stop instead of spinning + if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN) + break; + } + EXPECT_TRUE(buf.empty()); + this->read_into_(received); + return received; + } + + struct Stall { + size_t filler; + std::vector first, second, received; + TestOverflowBuffer::Storage before; + }; + /// Park two messages, then drain the first fully and the second part way + void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) { + s.filler = this->fill_pipe_(); + s.first = make_message(1500, 20); + ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go + // Larger than the whole pipe, so a drain always stops inside it + s.second = make_message(std::max(s.filler + 1, std::min(s.filler * 3, 12000)), 60); + ASSERT_GT(s.second.size(), s.filler); + ASSERT_TRUE(enqueue(buf, s.first)); + ASSERT_TRUE(enqueue(buf, s.second)); + s.before = buf.storage(); + this->read_into_(s.received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + } + + int reader_{-1}; + std::unique_ptr sock_; +}; + +TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) { + TestOverflowBuffer buf; + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 1); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const auto storage = buf.storage(); + EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX); + + for (int stall = 0; stall < 5; stall++) { + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + // Same allocation every time: no free, no new allocation + EXPECT_EQ(buf.storage(), storage); + + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.storage(), storage); + } +} + +TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 7); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const size_t capacity = buf.capacity(); + + // Requested while the backlog still holds data: storage must stay until sent + buf.release(); + EXPECT_FALSE(buf.empty()); + EXPECT_EQ(buf.capacity(), capacity); + + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); + + // A later stall allocates again and keeps it, since nobody asked for a release + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_GT(buf.capacity(), 0u); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) { + TestOverflowBuffer buf; + auto msg = make_message(100, 3); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); + + buf.release(); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) { + TestOverflowBuffer buf; + auto first = make_message(700, 10); + auto second_a = make_message(300, 50); + auto second_b = make_message(400, 90); + auto third = make_message(200, 130); + + size_t filler = this->fill_pipe_(); + // 100 bytes of the first message were already accepted by the socket + ASSERT_TRUE(enqueue(buf, first, 100)); + // Two iovecs with the skip covering all of the first one plus part of the second + struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}}; + const uint16_t second_skip = static_cast(second_a.size() + 5); + ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast(second_a.size() + second_b.size()), second_skip)); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 3); + + // Nothing can go out while the pipe is full + EXPECT_EQ(this->drain_(buf), -1); + EXPECT_EQ(buf.count(), 3); + + std::vector expected; + append(expected, first, 100); + append(expected, second_b, 5); + append(expected, third); + expect_after_filler(this->drain_all_(buf), filler, expected); +} + +TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) { + TestOverflowBuffer buf; + auto msg = make_message(16, 1); + + size_t filler = this->fill_pipe_(); + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) { + ASSERT_TRUE(enqueue(buf, msg)) << "message " << i; + } + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE); + + // Draining frees the slots again + std::vector expected; + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) + append(expected, msg); + expect_after_filler(this->drain_all_(buf), filler, expected); + this->fill_pipe_(); + EXPECT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 1); +} + +TEST_F(OverflowBufferTest, SkipAtIovecBoundary) { + TestOverflowBuffer buf; + auto sent = make_message(300, 50); + auto unsent = make_message(400, 90); + + size_t filler = this->fill_pipe_(); + // The skip covers the first iovec exactly, so only the second is copied + struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}}; + ASSERT_TRUE( + buf.enqueue_iov(iov, 2, static_cast(sent.size() + unsent.size()), static_cast(sent.size()))); + EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX); + expect_after_filler(this->drain_all_(buf), filler, unsent); +} + +TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) { + TestOverflowBuffer buf; + size_t filler = this->fill_pipe_(); + auto first = make_message(200, 20); + // Size the second message so the two land half way into a 256 byte step, + // leaving exactly 128 bytes of slack whatever the pipe accepted + const size_t base = std::max(filler + 1, std::min(filler * 3, 12000)); + const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + auto second = make_message(second_len, 60); + ASSERT_GT(second.size(), filler); + ASSERT_TRUE(enqueue(buf, first)); + ASSERT_TRUE(enqueue(buf, second)); + const auto storage = buf.storage(); + const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + ASSERT_EQ(slack, 128u); + auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200); + + std::vector received; + this->read_into_(received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + const size_t live = buf.live(); + + // Fits in the tail, so the sent prefix is left alone + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), storage); + EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX); + + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, concat({first, second, third})); +} + +TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) { + TestOverflowBuffer buf; + auto first = make_message(300, 7); + auto second = make_message(300, 70); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + buf.release(); + ASSERT_TRUE(enqueue(buf, second)); + EXPECT_GT(buf.capacity(), 0u); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) { + TestOverflowBuffer buf; + // Two of these fill the byte budget exactly, well before the slot count is reached + static_assert(API_MAX_SEND_QUEUE >= 3); + auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 2); +} + +TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) { + TestOverflowBuffer buf; + // The oversized message must still fit under the lone message ceiling + static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <= + TestOverflowBuffer::MAX_LONE_BYTES); + auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5); + auto small = make_message(16, 9); + + // Refusing the only message would drop the connection for nothing + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, big)); + EXPECT_EQ(buf.count(), 1); + // With a backlog present the byte limit applies again + EXPECT_FALSE(enqueue(buf, small)); + EXPECT_EQ(buf.count(), 1); + + expect_after_filler(this->drain_all_(buf), filler, big); +} + +TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) { + TestOverflowBuffer buf; + // Payload plus prefix is past the lone message ceiling + auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3); + + this->fill_pipe_(); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + // A closed socket fails every write outright, unlike a full one + ASSERT_EQ(this->sock_->close(), 0); + + errno = 0; + EXPECT_EQ(buf.try_drain(this->sock_.get()), -1); + EXPECT_NE(errno, EWOULDBLOCK); + EXPECT_NE(errno, EAGAIN); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX); +} + +TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + + // One byte too many to fit even after the sent prefix is reclaimed: grows in one copy + auto third = make_message(s.before.capacity - buf.live() + 1, 200); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_GT(buf.capacity(), s.before.capacity); + EXPECT_EQ(buf.count(), 2); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + std::vector received; + this->read_into_(received); + + // Room is available, but a nested drain must leave the outer one's message alone + buf.set_draining(true); + EXPECT_EQ(this->drain_(buf), 0); + EXPECT_EQ(buf.count(), 1); + std::vector nothing; + this->read_into_(nothing); + EXPECT_TRUE(nothing.empty()); + + buf.set_draining(false); + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, msg); +} + +TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(4, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + buf.set_draining(true); + EXPECT_TRUE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 2); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(100, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + // Growing would free the bytes the outer write() is sending from + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, first); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // Sliding the remainder down would move the bytes the outer write() points at + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), s.before); + buf.set_draining(false); + + // Once the drain is over the same enqueue compacts and succeeds + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // The sent first message is reclaimed by sliding the remainder down, not by reallocating + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +} // namespace esphome::api::testing +#endif // USE_HOST diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp index 9ea6ce1cd97..46af7cd836e 100644 --- a/tests/components/api/test_proto_mac_varint.cpp +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -59,7 +59,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) { #ifdef ESPHOME_DEBUG_API uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size(); #endif - ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); + pos = ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); size_t new_len = pos - api_buf.data(); EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec; diff --git a/tests/components/atm90e32/__init__.py b/tests/components/atm90e32/__init__.py new file mode 100644 index 00000000000..37d6797e2dd --- /dev/null +++ b/tests/components/atm90e32/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["sensor", "spi"] diff --git a/tests/components/atm90e32/offset_register_verification_test.cpp b/tests/components/atm90e32/offset_register_verification_test.cpp new file mode 100644 index 00000000000..3bb3eb76ea9 --- /dev/null +++ b/tests/components/atm90e32/offset_register_verification_test.cpp @@ -0,0 +1,62 @@ +#include + +#include "esphome/components/atm90e32/atm90e32.h" + +namespace esphome::atm90e32::testing { + +TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) { + EXPECT_TRUE(offset_register_value_matches(0x007B, 123)); + EXPECT_TRUE(offset_register_value_matches(0xFF85, -123)); +} + +TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) { + EXPECT_FALSE(offset_register_value_matches(0x007C, 123)); + EXPECT_FALSE(offset_register_value_matches(0xFF84, -123)); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) { + const auto state = resolve_offset_restore_state(true, true, false); + + EXPECT_TRUE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) { + const auto state = resolve_offset_restore_state(true, false, true); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) { + const auto state = resolve_offset_restore_state(true, false, false); + + EXPECT_FALSE(state.restored); + EXPECT_FALSE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) { + const auto state = resolve_offset_restore_state(false, true, false); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) { + const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}}; + OffsetCalibration rollback[3]{}; + + prepare_offset_rollback(previous, true, rollback); + for (uint8_t phase = 0; phase < 3; phase++) { + EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset); + EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset); + } + + prepare_offset_rollback(previous, false, rollback); + for (const auto &phase : rollback) { + EXPECT_EQ(phase.first_offset, 0); + EXPECT_EQ(phase.second_offset, 0); + } +} + +} // namespace esphome::atm90e32::testing diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index d0a16cc99c8..a0368c44b97 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -37,6 +37,27 @@ binary_sensor: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor + - binary_sensor.template.publish: + id: binary_sensor_publish_target + state: true + - binary_sensor.template.publish: + id: binary_sensor_publish_target + state: !lambda "return x.value_or(false);" + - if: + condition: + binary_sensor.is_on: binary_sensor_publish_target + then: + - logger.log: "publish_target is on" + - if: + condition: + binary_sensor.is_off: + id: binary_sensor_publish_target + then: + - logger.log: "publish_target is off" + + - platform: template + id: binary_sensor_publish_target + name: "Publish Target" # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 00000000000..20b69b6c64b --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d4..45bf77b4e84 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing diff --git a/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml b/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml new file mode 100644 index 00000000000..f46f4814c2e --- /dev/null +++ b/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml @@ -0,0 +1,12 @@ +# Compile the gated filter path; no external component is in-tree to call +# enable_advertisement_filter(), so the define is forced here. +<<: !include common.yaml + +esphome: + build_flags: + - "-DUSE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" + +esp32_ble_tracker: + +bluetooth_proxy: + active: true diff --git a/tests/components/bm8563/common-actions.yaml b/tests/components/bm8563/common-actions.yaml new file mode 100644 index 00000000000..c8175e3177a --- /dev/null +++ b/tests/components/bm8563/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: BM8563 Actions + on_press: + - bm8563.read_time: + - bm8563.start_timer: + duration: !lambda return 60; diff --git a/tests/components/bm8563/test-actions.esp8266-ard.yaml b/tests/components/bm8563/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..eaabbc63fe9 --- /dev/null +++ b/tests/components/bm8563/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bm8563: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/button/common.yaml b/tests/components/button/common.yaml index d5978601f43..352a4c29234 100644 --- a/tests/components/button/common.yaml +++ b/tests/components/button/common.yaml @@ -4,3 +4,9 @@ button: id: some_button on_press: - logger.log: Button pressed + +esphome: + on_boot: + - button.press: some_button + - button.press: + id: some_button diff --git a/tests/components/cdc_acm_uart/common.yaml b/tests/components/cdc_acm_uart/common.yaml new file mode 100644 index 00000000000..6c43dfc18b3 --- /dev/null +++ b/tests/components/cdc_acm_uart/common.yaml @@ -0,0 +1,18 @@ +tinyusb: + id: tinyusb_test + usb_lang_id: 0x0123 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_serial_str: ESPHomeTestSerialNumber + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 diff --git a/tests/components/cdc_acm_uart/common_dual.yaml b/tests/components/cdc_acm_uart/common_dual.yaml new file mode 100644 index 00000000000..0ce817fbc20 --- /dev/null +++ b/tests/components/cdc_acm_uart/common_dual.yaml @@ -0,0 +1,12 @@ +# Second UART/CDC pair for a two-bridge setup. Kept out of common.yaml because the +# ESP32-S2 has only two UART controllers and the logger occupies one, so a second +# uart there would fail at runtime. +uart: + - id: uart_1 + tx_pin: 15 + rx_pin: 16 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_2 diff --git a/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..aa9ec8079f2 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml @@ -0,0 +1,15 @@ +packages: + cdc_acm_uart: !include common.yaml + cdc_acm_uart_dual: !include common_dual.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 + - platform: cdc_acm_uart + uart_id: uart_1 + usb_cdc_acm_id: cdc_acm_2 + dtr_pin: 20 + rts_pin: 21 diff --git a/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..0beeb80bfa4 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml @@ -0,0 +1,14 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + cdc_acm_uart: !include common.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 diff --git a/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..cbb1fc2a3a5 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml @@ -0,0 +1,17 @@ +packages: + cdc_acm_uart: !include common.yaml + cdc_acm_uart_dual: !include common_dual.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 + - platform: cdc_acm_uart + uart_id: uart_1 + usb_cdc_acm_id: cdc_acm_2 + # GPIO19/20 are USB D-/D+ on the S3 (which the CDC side itself uses); use + # unrelated free pins here. + dtr_pin: 17 + rts_pin: 18 diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index 49386a16d59..3e305d921a4 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -56,8 +56,8 @@ climate: default_target_temperature_high: 22°C button: - # Exercise the climate.control: action so ControlAction templates get - # build coverage. Various field combinations are tested. + # Exercise the climate.control: action so the register_apply_action codegen + # gets build coverage. Various field combinations are tested. - platform: template name: "Climate Control Mode" on_press: @@ -84,6 +84,22 @@ button: - climate.control: id: climate_test_thermostat mode: "OFF" + # Custom strings take the (const char *, size_t) overload; the byte length of + # the non-ASCII value is computed at codegen time. + - platform: template + name: "Climate Control Custom Strings" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: "Über" + custom_preset: Default + - platform: template + name: "Climate Control Custom Lambdas" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: !lambda return "quiet"; + custom_preset: !lambda return "Default"; # Exercise climate.control inside a trigger with non-empty Ts (number on_value # passes float). diff --git a/tests/components/climate_ir/__init__.py b/tests/components/climate_ir/__init__.py new file mode 100644 index 00000000000..ae68ae228af --- /dev/null +++ b/tests/components/climate_ir/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ClimateIR derives from climate::Climate without declaring it as a dependency. + manifest.dependencies = manifest.dependencies + ["climate"] diff --git a/tests/components/climate_ir/climate_ir_test.cpp b/tests/components/climate_ir/climate_ir_test.cpp new file mode 100644 index 00000000000..25e894a360a --- /dev/null +++ b/tests/components/climate_ir/climate_ir_test.cpp @@ -0,0 +1,83 @@ +#include +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::climate_ir::testing { + +class TestClimateIR : public ClimateIR { + public: + explicit TestClimateIR(bool supports_dry = false, bool supports_fan_only = false) + : ClimateIR(16.0f, 30.0f, 1.0f, supports_dry, supports_fan_only) {} + + using ClimateIR::traits; + + protected: + void transmit_state() override {} +}; + +// The HEAT_COOL default is covered in tests/component_tests/climate_ir. + +TEST(ClimateIRTest, HeatCoolAdvertisedWhenSupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolNotAdvertisedWhenUnsupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolAdvertisedForCoolOnlyDeviceThatSupportsIt) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); +} + +TEST(ClimateIRTest, DefaultModes) { + TestClimateIR climate; + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_DRY)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY)); +} + +TEST(ClimateIRTest, DryAndFanOnlyFromConstructor) { + TestClimateIR climate(true, true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_DRY)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY)); +} + +TEST(ClimateIRTest, SetterCanTurnAModeBackOn) { + TestClimateIR climate; + climate.set_supports_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL)); + climate.set_supports_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL)); +} + +} // namespace esphome::climate_ir::testing diff --git a/tests/components/cm1106/common-actions.yaml b/tests/components/cm1106/common-actions.yaml new file mode 100644 index 00000000000..dd6115d37a5 --- /dev/null +++ b/tests/components/cm1106/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: CM1106 Calibrate Zero + on_press: + - cm1106.calibrate_zero: diff --git a/tests/components/cm1106/test-actions.esp8266-ard.yaml b/tests/components/cm1106/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..04d7c1aa5d7 --- /dev/null +++ b/tests/components/cm1106/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + cm1106: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index baf688fc8a3..72af605d61f 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -348,4 +348,75 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) { EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); } +// --- FixedVector::try_init() --- + +// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check +static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); } + +TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { + FixedVector v; + const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t)); + escape(&v); + EXPECT_FALSE(ok); + EXPECT_EQ(v.capacity(), 0u); + EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap + EXPECT_EQ(v.capacity(), 0u); + EXPECT_TRUE(v.try_init(0)); + EXPECT_TRUE(v.try_init(4)); + v.push_back(7); + EXPECT_EQ(v.size(), 1u); +} + +// --- RAMAllocator::make_unique() --- + +namespace { +struct Probe { + static inline int live = 0; + int a; + int b; + Probe(int a, int b) : a(a), b(b) { live++; } + ~Probe() { live--; } +}; +} // namespace + +static_assert(sizeof(RAMUniquePtr) == sizeof(Probe *), "the deleter must not add storage"); + +TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) { + auto p = RAMAllocator().make_unique(3, 4); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 3); + EXPECT_EQ(p->b, 4); + EXPECT_EQ(Probe::live, 1); + p.reset(); + EXPECT_EQ(Probe::live, 0); +} + +TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) { + struct Plain { + uint32_t words[8]; + }; + // Dirty a block of the same size first so a recycled allocation is not zero by chance + auto dirty = RAMAllocator().make_unique_array_for_overwrite(sizeof(Plain)); + std::memset(dirty.get(), 0xFF, sizeof(Plain)); + dirty.reset(); + auto p = RAMAllocator().make_unique(); + ASSERT_NE(p, nullptr); + // Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused + EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; })); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) { + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr); + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(0), nullptr); + EXPECT_NE(RAMAllocator().make_unique_array_for_overwrite(1), nullptr); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) { + RAMUniquePtr buf = RAMAllocator().make_unique_array_for_overwrite(256); + ASSERT_NE(buf, nullptr); + std::memset(buf.get(), 0xA5, 256); + EXPECT_EQ(buf[0], 0xA5); + EXPECT_EQ(buf[255], 0xA5); +} + } // namespace esphome::core::testing diff --git a/tests/components/core/test_string_ref.cpp b/tests/components/core/test_string_ref.cpp index bcbd0aa0d46..f66800dd178 100644 --- a/tests/components/core/test_string_ref.cpp +++ b/tests/components/core/test_string_ref.cpp @@ -2,6 +2,9 @@ #include "esphome/core/string_ref.h" +#include +#include + namespace esphome::core::testing { TEST(StringRefStartsWith, ProperPrefixMatches) { @@ -59,4 +62,103 @@ TEST(StringRefStartsWith, RefOverloadComparesOnlyTheViewedLength) { EXPECT_TRUE(ref.starts_with(prefix)); } +// The generated api messages start their encode only string fields as a null pointer with zero +// length; every member must treat that exactly like the default constructed empty string. +TEST(StringRefNullEmpty, BehavesAsEmptyString) { + const StringRef null_empty{nullptr, 0}; + const StringRef empty; + EXPECT_TRUE(null_empty.empty()); + EXPECT_EQ(null_empty.size(), 0u); + EXPECT_EQ(null_empty.c_str(), nullptr); + EXPECT_EQ(null_empty.byte(), nullptr); + EXPECT_TRUE(null_empty == empty); + EXPECT_FALSE(null_empty < empty); + EXPECT_FALSE(empty < null_empty); + EXPECT_TRUE(null_empty == ""); // NOLINT(readability-container-size-empty) - operator under test + EXPECT_TRUE(null_empty == std::string()); // NOLINT(readability-container-size-empty) - operator under test + EXPECT_EQ(null_empty.compare(empty), 0); + EXPECT_EQ(null_empty.compare(""), 0); + EXPECT_LT(null_empty.compare("a"), 0); + EXPECT_TRUE(null_empty.starts_with("")); + EXPECT_FALSE(null_empty.starts_with("a")); + EXPECT_EQ(null_empty.str(), std::string()); + EXPECT_EQ(null_empty.substr(0), std::string()); + EXPECT_EQ(null_empty.find('a'), std::string::npos); + EXPECT_EQ(null_empty.find("a"), std::string::npos); + char buf[4] = "xyz"; + EXPECT_EQ(null_empty.copy(buf, sizeof(buf)), 0u); + EXPECT_EQ(null_empty.begin(), null_empty.end()); +} + +TEST(StringRefNullEmpty, ComparesAgainstText) { + const StringRef null_empty{nullptr, 0}; + const StringRef text("abc", 3); + EXPECT_FALSE(null_empty == text); + EXPECT_FALSE(text == null_empty); + EXPECT_LT(null_empty.compare(text), 0); + EXPECT_GT(text.compare(null_empty), 0); + EXPECT_TRUE(null_empty < text); + EXPECT_FALSE(text < null_empty); + EXPECT_TRUE(text.starts_with(null_empty)); +} + +TEST(StringRefNullEmpty, TwoNullViewsAreEqual) { + const StringRef a{nullptr, 0}; + const StringRef b{nullptr, 0}; + EXPECT_TRUE(a == b); + EXPECT_FALSE(a < b); + EXPECT_EQ(a.compare(b), 0); + EXPECT_TRUE(a.starts_with(b)); +} + +// Every iterator endpoint of a null view is the same null position: nothing is dereferenced and +// no offset is applied to the null pointer, so the range is simply empty. +TEST(StringRefNullEmpty, IteratorEndpointsFormAnEmptyRange) { + const StringRef null_empty{nullptr, 0}; + EXPECT_EQ(null_empty.cbegin(), null_empty.cend()); + EXPECT_EQ(null_empty.rbegin(), null_empty.rend()); + EXPECT_EQ(null_empty.crbegin(), null_empty.crend()); + EXPECT_EQ(std::distance(null_empty.begin(), null_empty.end()), 0); + size_t visited = 0; + for (char c : null_empty) { + (void) c; + visited++; + } + EXPECT_EQ(visited, 0u); + // NOLINTNEXTLINE(bugprone-string-constructor) - empty range under test + EXPECT_EQ(std::string(null_empty.begin(), null_empty.end()), std::string()); +} + +// The pointer and length constructor accepts an empty range at a null pointer; the copy into a +// std::string reads nothing. +TEST(StringRefNullEmpty, ConvertsToEmptyStdString) { + const StringRef null_empty{nullptr, 0}; + const std::string copy = null_empty.str(); + EXPECT_TRUE(copy.empty()); + EXPECT_EQ(static_cast(null_empty), std::string()); + EXPECT_EQ(null_empty.substr(0, 5), std::string()); + std::string target("keep"); + target += null_empty; + EXPECT_EQ(target, "keep"); +} + +// The number conversions hand the pointer to the C library; a null view must stop before that. +TEST(StringRefNullEmpty, NumericConversionsReturnZero) { + const StringRef null_empty{nullptr, 0}; + size_t pos = 99; + EXPECT_EQ(stoi(null_empty, &pos), 0); + EXPECT_EQ(pos, 0u); + pos = 99; + EXPECT_EQ(stol(null_empty, &pos, 16), 0L); + EXPECT_EQ(pos, 0u); + pos = 99; + EXPECT_EQ(stof(null_empty, &pos), 0.0f); + EXPECT_EQ(pos, 0u); + pos = 99; + EXPECT_EQ(stod(null_empty, &pos), 0.0); + EXPECT_EQ(pos, 0u); + EXPECT_EQ(stoi(null_empty), 0); + EXPECT_EQ(stof(null_empty), 0.0f); +} + } // namespace esphome::core::testing diff --git a/tests/components/counter/common.h b/tests/components/counter/common.h new file mode 100644 index 00000000000..ac9cf7f5a98 --- /dev/null +++ b/tests/components/counter/common.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/components/counter/counter_sensor.h" + +namespace esphome::counter::testing { + +constexpr int64_t INT64_MAX_VALUE = std::numeric_limits::max(); +constexpr int64_t INT64_MIN_VALUE = std::numeric_limits::min(); + +/// Stands in for a binary sensor: reports each state to its callbacks. +struct FakeBinarySource { + template void add_on_state_callback(F &&callback) { this->callbacks_.add(std::forward(callback)); } + void publish(bool state) { this->callbacks_.call(state); } + CallbackManager callbacks_; +}; + +// Restore is off so no preference storage is needed. +class CounterTest : public ::testing::Test { + protected: + CounterSensor counter_{false, 0}; +}; + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/common.yaml b/tests/components/counter/common.yaml new file mode 100644 index 00000000000..6a0d8aecc8b --- /dev/null +++ b/tests/components/counter/common.yaml @@ -0,0 +1,37 @@ +esphome: + on_boot: + then: + - counter.set_value: + id: counter_sensor + value: 100 + - counter.increment: counter_sensor + - counter.increment: + id: counter_sensor + value: -5 + - counter.increment: + id: counter_sensor + value: !lambda "return 5000000000LL;" + +sensor: + - platform: counter + id: counter_sensor + name: Counter + - platform: counter + id: counter_no_restore + name: Counter No Restore + restore: false + initial_value: 10 + - platform: template + id: source_sensor + lambda: return 1.0; + update_interval: 1s + - platform: counter + name: Counter Of Sensor + sensor: source_sensor + - platform: counter + name: Counter Of Binary Sensor + binary_sensor: source_binary_sensor + +binary_sensor: + - platform: template + id: source_binary_sensor diff --git a/tests/components/counter/sensor/test_counter.cpp b/tests/components/counter/sensor/test_counter.cpp new file mode 100644 index 00000000000..a0431062e8d --- /dev/null +++ b/tests/components/counter/sensor/test_counter.cpp @@ -0,0 +1,84 @@ +#include "../common.h" + +namespace esphome::counter::testing { + +TEST_F(CounterTest, StartsAtZero) { + this->counter_.setup(); + EXPECT_EQ(this->counter_.state, 0.0f); +} + +TEST(CounterInitialValue, PublishedAtSetupWhenNotRestoring) { + CounterSensor counter(false, -12); + counter.setup(); + EXPECT_EQ(counter.state, -12.0f); + counter.increment(); + EXPECT_EQ(counter.state, -11.0f); +} + +TEST_F(CounterTest, SetValuePublishesState) { + this->counter_.set_value(42); + EXPECT_EQ(this->counter_.state, 42.0f); +} + +TEST_F(CounterTest, IncrementDefaultsToOne) { + this->counter_.increment(); + this->counter_.increment(); + EXPECT_EQ(this->counter_.state, 2.0f); +} + +TEST_F(CounterTest, IncrementAcceptsNegativeAmounts) { + this->counter_.set_value(10); + this->counter_.increment(-25); + EXPECT_EQ(this->counter_.state, -15.0f); +} + +TEST_F(CounterTest, ValueBeyondInt32) { + this->counter_.set_value(5000000000LL); + this->counter_.increment(5000000000LL); + EXPECT_EQ(this->counter_.state, 1.0e10f); +} + +TEST_F(CounterTest, IncrementWrapsAtInt64Limits) { + this->counter_.set_value(INT64_MAX_VALUE); + this->counter_.increment(1); + EXPECT_EQ(this->counter_.state, static_cast(INT64_MIN_VALUE)); + + this->counter_.set_value(INT64_MIN_VALUE); + this->counter_.increment(-1); + EXPECT_EQ(this->counter_.state, static_cast(INT64_MAX_VALUE)); +} + +TEST_F(CounterTest, CountsEachPublishFromSource) { + sensor::Sensor source; + this->counter_.count_updates_from(&source); + + // The counted value is unrelated to what the source publishes. + source.publish_state(10.0f); + source.publish_state(10.0f); + source.publish_state(-3.5f); + EXPECT_EQ(this->counter_.state, 3.0f); +} + +TEST_F(CounterTest, SourceUpdatesAddToCurrentValue) { + sensor::Sensor source; + this->counter_.count_updates_from(&source); + + this->counter_.set_value(100); + source.publish_state(1.0f); + EXPECT_EQ(this->counter_.state, 101.0f); +} + +TEST_F(CounterTest, CountsOnlyTrueFromBinarySource) { + FakeBinarySource source; + this->counter_.count_true_from(&source); + + source.publish(true); + source.publish(false); + EXPECT_EQ(this->counter_.state, 1.0f); + source.publish(true); + source.publish(false); + source.publish(false); + EXPECT_EQ(this->counter_.state, 2.0f); +} + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/test.esp32-idf.yaml b/tests/components/counter/test.esp32-idf.yaml new file mode 100644 index 00000000000..cc0d0d4e7f1 --- /dev/null +++ b/tests/components/counter/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + counter: !include common.yaml diff --git a/tests/components/counter/validate.esp32-idf.yaml b/tests/components/counter/validate.esp32-idf.yaml new file mode 100644 index 00000000000..22751677af6 --- /dev/null +++ b/tests/components/counter/validate.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + counter: !include common.yaml + +esphome: + on_shutdown: + then: + - counter.set_value: + id: counter_no_restore + value: -9223372036854775807 + - counter.increment: + id: counter_no_restore + value: 9223372036854775807 diff --git a/tests/components/datetime/common.yaml b/tests/components/datetime/common.yaml index 4e26b681212..add75645292 100644 --- a/tests/components/datetime/common.yaml +++ b/tests/components/datetime/common.yaml @@ -1,3 +1,43 @@ +esphome: + on_boot: + then: + - datetime.date.set: + id: datetime_test_date + date: + year: 2021 + month: 1 + day: 1 + - datetime.date.set: + id: datetime_test_date + date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};" + - datetime.time.set: + id: datetime_test_time + time: "12:34:56" + - datetime.time.set: + id: datetime_test_time + time: !lambda "return {.second = 56, .minute = 34, .hour = 12};" + - datetime.datetime.set: + id: datetime_test_datetime + datetime: "2021-01-01 12:34:56" + - datetime.datetime.set: + id: datetime_test_datetime + datetime: !lambda "return {.second = 56, .minute = 34, .hour = 12, .day_of_month = 1, .month = 1, .year = 2021};" + datetime: + - platform: template + name: Datetime Test Date + id: datetime_test_date + type: date + optimistic: true + - platform: template + name: Datetime Test Time + id: datetime_test_time + type: time + optimistic: true + - platform: template + name: Datetime Test DateTime + id: datetime_test_datetime + type: datetime + optimistic: true time: diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml index 9dc2f872588..68ef8f7b584 100644 --- a/tests/components/deep_sleep/common-esp32-all.yaml +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml index c531d447438..e22436dd648 100644 --- a/tests/components/deep_sleep/common-esp32-ext1.yaml +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common-esp32.yaml b/tests/components/deep_sleep/common-esp32.yaml index e670787cc0b..248f527616d 100644 --- a/tests/components/deep_sleep/common-esp32.yaml +++ b/tests/components/deep_sleep/common-esp32.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index 7a1a709965b..6c6222ff875 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,6 +4,10 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - deep_sleep.prevent: + id: deep_sleep_1 + - deep_sleep.allow: + id: deep_sleep_1 - if: condition: lambda: 'return false;' diff --git a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml index 485490576d8..b59b927e0a1 100644 --- a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml +++ b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml @@ -6,6 +6,7 @@ packages: deep_sleep: !include common.yaml deep_sleep: + id: deep_sleep_1 run_duration: 10s zephyr_ble_server: diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml index bdbd27c902e..df0c85d7088 100644 --- a/tests/components/deep_sleep/test.bk72xx-ard.yaml +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 30s sleep_duration: 12h on_wake: diff --git a/tests/components/deep_sleep/test.esp8266-ard.yaml b/tests/components/deep_sleep/test.esp8266-ard.yaml index e4c592c0951..8e5b1ca4f72 100644 --- a/tests/components/deep_sleep/test.esp8266-ard.yaml +++ b/tests/components/deep_sleep/test.esp8266-ard.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 10s sleep_duration: 50s on_wake: diff --git a/tests/components/deep_sleep/test.nrf52-adafruit.yaml b/tests/components/deep_sleep/test.nrf52-adafruit.yaml index 6362142be2e..bfc89303a93 100644 --- a/tests/components/deep_sleep/test.nrf52-adafruit.yaml +++ b/tests/components/deep_sleep/test.nrf52-adafruit.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 10s sleep_duration: 50s diff --git a/tests/components/dfplayer/common.yaml b/tests/components/dfplayer/common.yaml index 5d2540c2753..317ffb99002 100644 --- a/tests/components/dfplayer/common.yaml +++ b/tests/components/dfplayer/common.yaml @@ -1,20 +1,32 @@ esphome: on_boot: then: + - dfplayer.play_mp3: 3 + - dfplayer.play_mp3: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" - dfplayer.play: 5 - dfplayer.play: file: 4 loop: true + - dfplayer.play: + file: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" + loop: !lambda return !id(dfplayer_test).is_playing(); - dfplayer.play_folder: folder: 1 file: 3 - dfplayer.play_folder: folder: 1 loop: true + - dfplayer.play_folder: + folder: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" + file: !lambda "return id(dfplayer_test).is_playing() ? 4 : 3;" + loop: !lambda return !id(dfplayer_test).is_playing(); - dfplayer.set_device: device: TF_CARD + - dfplayer.set_device: USB - dfplayer.set_volume: 5 + - dfplayer.set_volume: !lambda "return id(dfplayer_test).is_playing() ? 10 : 5;" - dfplayer.set_eq: ROCK + - dfplayer.set_eq: !lambda "return id(dfplayer_test).is_playing() ? dfplayer::JAZZ : dfplayer::BASS;" - dfplayer.play_next - dfplayer.play_previous - dfplayer.reset @@ -25,8 +37,17 @@ esphome: - dfplayer.volume_up - dfplayer.volume_down - dfplayer.sleep + - dfplayer.set_current_track_repeat + - dfplayer.set_current_track_repeat: true + - dfplayer.set_current_track_repeat: + enable: false + - dfplayer.set_current_track_repeat: + enable: !lambda return true; + - dfplayer.set_current_track_repeat: + id: dfplayer_test dfplayer: + id: dfplayer_test on_finished_playback: then: if: diff --git a/tests/components/ds1307/common-actions.yaml b/tests/components/ds1307/common-actions.yaml new file mode 100644 index 00000000000..b6c03723b54 --- /dev/null +++ b/tests/components/ds1307/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: DS1307 Actions + on_press: + - ds1307.write_time: + id: ds1307_time + - ds1307.read_time: ds1307_time diff --git a/tests/components/ds1307/test-actions.esp8266-ard.yaml b/tests/components/ds1307/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..f2326f446c3 --- /dev/null +++ b/tests/components/ds1307/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds1307: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/duty_time/common-actions.yaml b/tests/components/duty_time/common-actions.yaml new file mode 100644 index 00000000000..18b1a7b6aac --- /dev/null +++ b/tests/components/duty_time/common-actions.yaml @@ -0,0 +1,15 @@ +button: + - platform: template + name: Duty Time Actions + on_press: + - if: + condition: + sensor.duty_time.is_running: pump_duty_time + then: + - sensor.duty_time.stop: pump_duty_time + - if: + condition: + sensor.duty_time.is_not_running: pump_duty_time + then: + - sensor.duty_time.start: pump_duty_time + - sensor.duty_time.reset: pump_duty_time diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 12e4397c491..a2a710e2d95 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -9,5 +9,6 @@ binary_sensor: sensor: - platform: duty_time + id: pump_duty_time name: Duty Time sensor: duty_time_bin1 diff --git a/tests/components/duty_time/test-actions.esp8266-ard.yaml b/tests/components/duty_time/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..bc514ad4c96 --- /dev/null +++ b/tests/components/duty_time/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + duty_time: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml new file mode 100644 index 00000000000..af8d44896be --- /dev/null +++ b/tests/components/emontx/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Send lambda command + on_press: + - emontx.send_command: + id: test_emontx + command: !lambda return "l"; diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..4be79a40dac --- /dev/null +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + emontx: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index 5ac12afa6a6..f21e674fde2 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -2,6 +2,9 @@ #include +#include +#include + #include "esphome/components/spi/spi.h" #include "esphome/core/hal.h" @@ -47,4 +50,51 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// SPI delegate that records what reaches the bus, filing each payload under the command it +/// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on +/// each data row, so a transfer can be driven past its yield deadline. +class RecordingDelegate : public spi::SPIDelegate { + public: + explicit RecordingDelegate(const RecordingPin *dc, uint32_t row_transfer_ms = 0) + : dc_(dc), row_transfer_ms_(row_transfer_ms) {} + + uint8_t transfer(uint8_t data) override { + this->record_(&data, 1); + return 0; + } + void write_array(const uint8_t *ptr, size_t length) override { + this->record_(ptr, length); + if (this->dc_->level && this->row_transfer_ms_ != 0) { + const uint32_t until = millis() + this->row_transfer_ms_; + while (millis() < until) { + } + } + } + + void clear() { + this->commands.clear(); + this->data.clear(); + } + + std::vector commands; + std::map> data; + + protected: + void record_(const uint8_t *ptr, size_t length) { + if (!this->dc_->level) { + for (size_t i = 0; i != length; i++) { + this->commands.push_back(ptr[i]); + this->last_command_ = ptr[i]; + } + return; + } + auto &payload = this->data[this->last_command_]; + payload.insert(payload.end(), ptr, ptr + length); + } + + const RecordingPin *dc_; + uint32_t row_transfer_ms_; + uint8_t last_command_{0}; +}; + } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp new file mode 100644 index 00000000000..1a66efe9839 --- /dev/null +++ b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp @@ -0,0 +1,298 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" + +namespace esphome::epaper_spi::testing { + +class TestableSSD1677Gray4 : public EPaperSSD1677Gray4 { + public: + TestableSSD1677Gray4(uint16_t width, uint16_t height) : EPaperSSD1677Gray4("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + /// As configured with monochrome_partial_updates: full_update_every > 1. + void install_with_partials(spi::SPIDelegate *delegate) { + this->install(delegate); + this->set_full_update_every(5); + this->init_comparison_frame_(); + ASSERT_TRUE(this->sent_.is_valid()); + } + + /// What the base class would decide; 0 means the next push is a full one. + void set_update_count(uint8_t count) { this->update_count_ = count; } + + /// Pretend only this rectangle changed. + void set_dirty(uint16_t x_low, uint16_t y_low, uint16_t x_high, uint16_t y_high) { + this->x_low_ = x_low; + this->y_low_ = y_low; + this->x_high_ = x_high; + this->y_high_ = y_high; + } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + using EPaperSSD1677Gray4::refresh_screen; + using EPaperSSD1677Gray4::transfer_data; + + RecordingPin dc; +}; + +using Bytes = std::vector; + +namespace { + +/// A gray that lands squarely on each of the four levels. +Color color_for_level(uint8_t level) { + static const uint8_t GRAYS[4] = {0, 64, 128, 255}; + const uint8_t v = GRAYS[level]; + return Color(v, v, v); +} + +void draw_row(TestableSSD1677Gray4 &display, int y, const std::vector &levels) { + for (size_t x = 0; x != levels.size(); x++) + display.draw_pixel_at((int) x, y, color_for_level(levels[x])); +} + +} // namespace + +/// Each pixel's 2-bit level is split across the RAM planes: the high bit to 0x24, the low bit to +/// 0x26, both inverted because the four-level waveform reads 1 as white. +TEST(EPaperSSD1677Gray4, SplitsEachLevelAcrossBothPlanes) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.run_push(); + + // levels 0 1 2 3 0 1 2 3 + // high bit 0 0 1 1 0 0 1 1 = 0x33, inverted 0xCC + // low bit 0 1 0 1 0 1 0 1 = 0x55, inverted 0xAA + EXPECT_EQ(bus.data[0x24], (Bytes{0xCC})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xAA})); +} + +/// Two buffer bytes (4 pixels each) make one plane byte (8 pixels), leftmost pixel in the most +/// significant bit. An asymmetric row catches a swapped pair or reversed bit order. +TEST(EPaperSSD1677Gray4, PacksPixelsLeftmostFirstAcrossSourceBytes) { + TestableSSD1677Gray4 display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_row(display, 0, {3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}); + display.run_push(); + + // high bits: pixel 0 (3) and pixel 15 (2) -> 0x80 0x01, inverted 0x7F 0xFE + // low bits: pixel 0 (3) only -> 0x80 0x00, inverted 0x7F 0xFF + EXPECT_EQ(bus.data[0x24], (Bytes{0x7F, 0xFE})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x7F, 0xFF})); +} + +/// The high-bit plane goes out first, each plane exactly once per push. +TEST(EPaperSSD1677Gray4, WritesTheHighBitPlaneBeforeTheLowBitPlane) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.run_push(); + + const auto &cmds = bus.commands; + ASSERT_EQ(std::count(cmds.begin(), cmds.end(), 0x24), 1); + ASSERT_EQ(std::count(cmds.begin(), cmds.end(), 0x26), 1); + EXPECT_LT(std::find(cmds.begin(), cmds.end(), 0x24) - cmds.begin(), + std::find(cmds.begin(), cmds.end(), 0x26) - cmds.begin()); +} + +/// A push that yields partway through must resume the right plane at the right row. +TEST(EPaperSSD1677Gray4, ResumesBothPlanesAfterYielding) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install(&bus); + + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + draw_row(display, 1, {1, 1, 1, 1, 1, 1, 1, 1}); + draw_row(display, 2, {2, 2, 2, 2, 2, 2, 2, 2}); + draw_row(display, 3, {3, 3, 3, 3, 3, 3, 3, 3}); + const int calls = display.run_push(); + + EXPECT_GT(calls, 2) << "the transfer never yielded, so this test proves nothing"; + // rows at levels 0..3: high bits 0 0 1 1, low bits 0 1 0 1, each inverted across the row + EXPECT_EQ(bus.data[0x24], (Bytes{0xFF, 0xFF, 0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0x00, 0xFF, 0x00})); +} + +/// Without partial updates enabled (the default) every refresh is the four-level sequence, even if +/// the update count says otherwise. +TEST(EPaperSSD1677Gray4, WithoutPartialUpdatesEveryRefreshIsFourLevel) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.set_update_count(1); + display.refresh_screen(true); + + EXPECT_EQ(bus.commands, (Bytes{0x1A, 0x22, 0x20})); + EXPECT_EQ(bus.data[0x1A], (Bytes{0x67, 0x00})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xD7})); +} + +// --- With monochrome partial updates ------------------------------------------------------------ + +/// A full update is still four-level. It also records, as the frame the next partial update +/// compares against, what the panel shows in black-and-white terms: the high bit of each level. +TEST(EPaperSSD1677Gray4, FullPushRecordsTheHighBitsForTheNextPartial) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.set_update_count(0); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0xCC})) << "full update is no longer the four-level split"; + EXPECT_EQ(bus.data[0x26], (Bytes{0xAA})); + bus.clear(); + + // Nothing changed: old and new planes must match, or the partial drives every pixel. + display.set_update_count(1); + display.run_push(); + EXPECT_EQ(bus.data[0x26], (Bytes{0x33})) << "comparison frame is not the high bits"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33})); +} + +/// A partial update sends the comparison frame to 0x26 and the new frame's high bits to 0x24, +/// not inverted (it runs the black-and-white waveform), over the whole panel. +TEST(EPaperSSD1677Gray4, PartialPushSendsTheHighBitsInBlackAndWhite) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3}); + draw_row(display, 1, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + draw_row(display, 1, {3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + display.set_dirty(0, 1, 8, 2); // only the start of the second row changed + display.set_update_count(1); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x33, 0xFF, 0x00, 0x00})) << "old plane is not the frame on the panel"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33, 0xFF, 0xF0, 0x00})) << "new plane is not the whole frame's high bits"; +} + +/// The new plane of a partial update is built a row at a time; a push that yields partway through +/// must resume at the right row. +TEST(EPaperSSD1677Gray4, PartialPushResumesAfterYielding) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install_with_partials(&bus); + + display.set_update_count(0); + display.run_push(); + bus.clear(); + + // The buffer starts white; darken all of row 0 and the right half of row 2 + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + draw_row(display, 2, {3, 3, 3, 3, 0, 0, 0, 0}); + display.set_update_count(1); + const int calls = display.run_push(); + + EXPECT_GT(calls, 2) << "the transfer never yielded, so this test proves nothing"; + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0xFF, 0xF0, 0xFF})); +} + +/// Regression test: a full update requested while a partial one is being sent must not switch the +/// push to the four-level transfer halfway, which misread the partial's progress and never finished. +TEST(EPaperSSD1677Gray4, FullUpdateRequestDuringAPartialPushWaitsForTheNextUpdate) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install_with_partials(&bus); + + display.set_update_count(0); + display.run_push(); + bus.clear(); + + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + display.set_update_count(1); + ASSERT_FALSE(display.transfer_data()); + display.request_full_update(); + int calls = 1; + while (!display.transfer_data()) + ASSERT_LT(++calls, 20) << "partial push never finished"; + + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0xFF, 0xFF, 0xFF})); + bus.clear(); + display.refresh_screen(true); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})) << "refresh does not match the partial data sent"; +} + +/// The four-level refresh follows a reset, which loses controller RAM, so it must send the whole +/// panel even when partial updates are enabled but the comparison frame could not be allocated. +TEST(EPaperSSD1677Gray4, FourLevelPushCoversTheWholePanelWithoutAComparisonFrame) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_full_update_every(5); // partial updates on, but no comparison frame + + display.set_dirty(8, 1, 16, 2); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x24].size(), 4u) << "four-level update did not send the whole new plane"; + EXPECT_EQ(bus.data[0x26].size(), 4u) << "four-level update did not send the whole old plane"; +} + +/// A full update resets the controller, which does not keep RAM, so even when only part of the +/// frame changed it must send the whole panel. +TEST(EPaperSSD1677Gray4, FullPushWithPartialsEnabledCoversTheWholePanel) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + display.set_dirty(8, 1, 16, 2); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x24].size(), 4u) << "full update did not send the whole new plane"; + EXPECT_EQ(bus.data[0x26].size(), 4u) << "full update did not send the whole old plane"; +} + +/// The refresh matches what was sent: black-and-white for a partial update, four-level for a full. +TEST(EPaperSSD1677Gray4, PartialRefreshIsBlackAndWhiteAndFullIsFourLevel) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + display.set_update_count(1); + display.refresh_screen(true); + EXPECT_EQ(bus.commands, (Bytes{0x3C, 0x22, 0x20})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})) << "partial update did not use the black-and-white waveform"; + // The model's border setting is right for the four-level waveform only; under this one it + // would drive the border black on every partial. + EXPECT_EQ(bus.data[0x3C], (Bytes{0x01})) << "partial update did not switch the border to LUT1"; + bus.clear(); + + display.set_update_count(0); + display.refresh_screen(false); + EXPECT_EQ(bus.data[0x22], (Bytes{0xD7})) << "full update did not use the four-level waveform"; + EXPECT_EQ(bus.data.count(0x3C), 0u) << "full update overrode the model's border setting"; +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp new file mode 100644 index 00000000000..cad853b1973 --- /dev/null +++ b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp @@ -0,0 +1,233 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677.h" + +namespace esphome::epaper_spi::testing { + +class TestableSSD1677 : public EPaperSSD1677 { + public: + TestableSSD1677(uint16_t width, uint16_t height) : EPaperSSD1677("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + this->init_comparison_frame_(); + } + + bool has_comparison_frame() const { return this->sent_.is_valid(); } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + } + + /// Fill the frame with a byte pattern that differs per seed; returns it. + std::vector set_pattern(uint8_t seed) { + std::vector frame; + for (size_t i = 0; i != this->buffer_length_; i++) { + frame.push_back((uint8_t) (seed + i * 7)); + this->buffer_[i] = frame.back(); + } + return frame; + } + + /// What the base class would decide; 0 means the next push is a full one. + void set_update_count(uint8_t count) { this->update_count_ = count; } + + /// Pretend only this rectangle changed. + void set_dirty(uint16_t x_low, uint16_t y_low, uint16_t x_high, uint16_t y_high) { + this->x_low_ = x_low; + this->y_low_ = y_low; + this->x_high_ = x_high; + this->y_high_ = y_high; + } + + /// One call into the transfer; false while there is more to send. + bool step() { return this->transfer_data(); } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + /// Run the UPDATE state, with nothing drawn, and report whether a push follows. + bool run_update_state() { + this->set_auto_clear(false); + this->set_dirty(this->width_, this->height_, 0, 0); + this->state_ = EPaperState::UPDATE; + this->process_state_(); + return this->state_ == EPaperState::RESET; + } + uint8_t update_count() const { return this->update_count_; } + + bool reset_in(EPaperState state) { + this->state_ = state; + return this->reset(); + } + + RecordingPin dc; + RecordingPin reset_pin; +}; + +using Bytes = std::vector; + +/// A full push ignores the old-image plane, and on the first push after boot the comparison frame +/// holds nothing real yet, so the new frame goes to both planes. +TEST(EPaperSSD1677, FullPushSendsTheNewFrameToBothPlanes) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); +} + +/// Regression test. +/// +/// A partial refresh drives every pixel from the pair (0x26 = the image on the panel, 0x24 = the +/// new image), across the whole panel whatever RAM window was written. The controller does not +/// keep its RAM intact between updates, so sending only the changed window of 0x24 - and 0x26 once +/// - leaves the pair wrong outside that window: unchanged pixels get driven on every partial and +/// wash out. Both planes must go out whole, 0x26 holding the frame actually on the panel. +TEST(EPaperSSD1677, PartialPushComparesAgainstTheFrameOnThePanel) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_dirty(8, 1, 16, 2); // only the last byte changed + display.set_update_count(1); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})) << "old plane is not the frame on the panel"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})) << "new plane is not the whole new frame"; + // The RAM window, set once per plane, must span the panel too, not the changed rectangle. + EXPECT_EQ(bus.data[0x44], (Bytes{0, 0, 15, 0, 0, 0, 15, 0})) << "x window is not the whole panel"; + EXPECT_EQ(bus.data[0x45], (Bytes{0, 0, 1, 0, 0, 0, 1, 0})) << "y window is not the whole panel"; +} + +/// The comparison frame must record the bytes that went to 0x24, not whatever the buffer holds +/// later: LVGL can draw into the buffer while a push is in progress. Here the buffer changes +/// between the two planes of a push; the next push must compare against what was actually sent. +TEST(EPaperSSD1677, ComparisonFrameIsWhatWasSentNotTheBuffer) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x11, 0x11, 0x11, 0x11}); + display.set_update_count(0); + display.run_push(); + + display.set_frame({0x22, 0x22, 0x22, 0x22}); + display.set_update_count(1); + ASSERT_FALSE(display.step()) << "expected the old plane to go out on its own first"; + display.set_frame({0x33, 0x33, 0x33, 0x33}); // drawn mid-push, before the new plane + while (!display.step()) { + } + ASSERT_EQ(bus.data[0x24].size(), 8u); + EXPECT_EQ(Bytes(bus.data[0x24].begin() + 4, bus.data[0x24].end()), (Bytes{0x33, 0x33, 0x33, 0x33})); + bus.clear(); + + display.set_frame({0x44, 0x44, 0x44, 0x44}); + display.set_update_count(2); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x33, 0x33, 0x33, 0x33})) << "old plane is not what was last sent"; +} + +/// Two full planes can take several loop iterations to send; each resumed call must continue the +/// right plane at the right byte. Planes go out in runs sized to the time slice, not row by row. +TEST(EPaperSSD1677, ResumesTheRightPlaneAfterYielding) { + // 400x100 is 5000 bytes per plane: two runs at the default 2 MHz bus + TestableSSD1677 display(400, 100); + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every run overruns the time slice + display.install(&bus, 5); + + const auto old_frame = display.set_pattern(1); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + const auto new_frame = display.set_pattern(2); + display.set_update_count(1); + const int calls = display.run_push(); + + EXPECT_EQ(calls, 4) << "expected two runs per plane, one per call"; + EXPECT_EQ(bus.data[0x26], old_frame); + EXPECT_EQ(bus.data[0x24], new_frame); +} + +/// A requested full update takes effect when the next update starts, and pushes the whole panel +/// even if nothing was drawn. +TEST(EPaperSSD1677, RequestedFullUpdateAppliesWhenTheNextUpdateStarts) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_update_count(3); + EXPECT_FALSE(display.run_update_state()) << "an update with nothing drawn should not push"; + + display.request_full_update(); + EXPECT_EQ(display.update_count(), 3) << "request changed the update in progress"; + EXPECT_TRUE(display.run_update_state()) << "requested full update did not push"; + EXPECT_EQ(display.update_count(), 0) << "requested update is not a full one"; + + display.set_update_count(3); + EXPECT_FALSE(display.run_update_state()) << "request was applied more than once"; +} + +/// Nothing a partial needs lives in controller RAM any more, so a partial push skips the reset +/// altogether; a full one still gets the hardware pulse and the software reset. +TEST(EPaperSSD1677, PartialPushSkipsTheResetAndAFullPushKeepsIt) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_update_count(1); + EXPECT_TRUE(display.reset_in(EPaperState::RESET)) << "partial push waited on a reset"; + EXPECT_TRUE(display.reset_pin.level) << "partial push pulsed the reset pin"; + EXPECT_TRUE(bus.commands.empty()) << "partial push sent a software reset"; + + display.set_update_count(0); + EXPECT_FALSE(display.reset_in(EPaperState::RESET)); + EXPECT_FALSE(display.reset_pin.level) << "full push did not pulse the reset pin"; + EXPECT_TRUE(display.reset_in(EPaperState::RESET_END)); + EXPECT_TRUE(display.reset_pin.level); + EXPECT_EQ(bus.commands, (Bytes{0x12})) << "full push did not send a software reset"; +} + +/// With every update a full one nothing is ever compared against 0x26, so no comparison frame is +/// allocated and the transfer is EPaperMono's. +TEST(EPaperSSD1677, NoComparisonFrameWhenEveryUpdateIsFull) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + EXPECT_FALSE(display.has_comparison_frame()); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 602aeb8d0e1..1678f463311 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -1,6 +1,14 @@ packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml +psram: + mode: octal + +esphome: + on_boot: + then: + - epaper_spi.full_update_next: epaper_partial + display: - platform: epaper_spi spi_id: spi_bus @@ -85,12 +93,24 @@ display: busy_pin: 37 enable_pin: 39 - platform: epaper_spi + id: epaper_partial model: seeed-ee04-mono-4.26 full_update_every: 10 # Override pins to avoid conflict with other display configs busy_pin: 43 dc_pin: 42 + # Seeed reTerminal Sticky, four-level grayscale (800x480, SSD1677) + # dc_pin/reset_pin overridden to avoid conflict with other display configs + # full_update_every is not supported by this model, so left at its default of 1 + - platform: epaper_spi + model: seeed-reterminal-sticky-gray4 + dc_pin: 45 + reset_pin: 9 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color(170, 170, 170)); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + # WeAct 2.13" 3-color e-paper (122x250, SSD1680) - platform: epaper_spi spi_id: spi_bus diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 523e614e240..5c7cb1d61b8 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -17,9 +17,12 @@ esp32: disable_dev_null_vfs: true disable_mbedtls_peer_cert: true disable_mbedtls_pkcs7: true + disable_mbedtls_tls_server: true + disable_mbedtls_tls_extras: true disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + flash_chip: gd watchdog_timeout: 7s wifi: diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index b9a3b804a8d..5bdf94e8e1d 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -9,6 +9,7 @@ esp32: type: esp-idf advanced: execute_from_psram: true + flash_chip: gd disable_libc_locks_in_iram: true # Test default RAM optimization enabled disable_debug_stubs: true disable_ocd_aware: true diff --git a/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml new file mode 100644 index 00000000000..fab0a64ab84 --- /dev/null +++ b/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml @@ -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 diff --git a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml new file mode 100644 index 00000000000..2f57abd296e --- /dev/null +++ b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim. +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 diff --git a/tests/components/esp8266_pwm/common.yaml b/tests/components/esp8266_pwm/common.yaml index 52b290f91bc..7144e89e367 100644 --- a/tests/components/esp8266_pwm/common.yaml +++ b/tests/components/esp8266_pwm/common.yaml @@ -1,3 +1,13 @@ +esphome: + on_boot: + then: + - output.esp8266_pwm.set_frequency: + id: out + frequency: 100Hz + - output.esp8266_pwm.set_frequency: + id: out2 + frequency: !lambda return 200.0f; + output: - platform: esp8266_pwm id: out diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 2f82e794c42..fec0dd9f3e8 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -38,8 +38,15 @@ espnow: data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' - espnow.peer.add: address: 11:22:33:44:55:66 + - espnow.peer.add: + address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};' - espnow.peer.delete: address: 11:22:33:44:55:66 + - espnow.peer.delete: + address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};' + - espnow.set_channel: 6 + - espnow.set_channel: + channel: !lambda 'return 6;' on_broadcast: - logger.log: format: "Broadcast from: %s = '%s' RSSI: %d" diff --git a/tests/components/ethernet/common-ksz8851snl.yaml b/tests/components/ethernet/common-ksz8851snl.yaml new file mode 100644 index 00000000000..aed9b744dcb --- /dev/null +++ b/tests/components/ethernet/common-ksz8851snl.yaml @@ -0,0 +1,19 @@ +ethernet: + type: KSZ8851SNL + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml b/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml new file mode 100644 index 00000000000..4b46dc8f538 --- /dev/null +++ b/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + ethernet: !include common-ksz8851snl.yaml diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml new file mode 100644 index 00000000000..d454dbadc39 --- /dev/null +++ b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml @@ -0,0 +1,15 @@ +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/event/common.yaml b/tests/components/event/common.yaml index 555d049c705..827f1ebb529 100644 --- a/tests/components/event/common.yaml +++ b/tests/components/event/common.yaml @@ -18,3 +18,15 @@ event: // Log using %.*s format for StringRef ESP_LOGD("test", "Event type: %.*s", (int) event_type.size(), event_type.c_str()); } + +button: + - platform: template + name: Trigger Event + on_press: + - event.trigger: + id: some_event + event_type: template_event_type1 + - event.trigger: + id: some_event + event_type: !lambda |- + return id(some_event).has_event() ? "template_event_type2" : "template_event_type1"; diff --git a/tests/components/ezo_pmp/common-actions.yaml b/tests/components/ezo_pmp/common-actions.yaml new file mode 100644 index 00000000000..2bb94247c63 --- /dev/null +++ b/tests/components/ezo_pmp/common-actions.yaml @@ -0,0 +1,20 @@ +button: + - platform: template + name: EZO PMP Actions + on_press: + - ezo_pmp.change_i2c_address: + id: hcl_pump + address: 104 + - ezo_pmp.change_i2c_address: + id: hcl_pump + address: !lambda return 105; + - ezo_pmp.dose_volume: + id: hcl_pump + volume: !lambda return 2.5; + - ezo_pmp.dose_volume_over_time: + id: hcl_pump + volume: !lambda return 2.5; + duration: !lambda return 3; + - ezo_pmp.arbitrary_command: + id: hcl_pump + command: !lambda return "D,?"; diff --git a/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml b/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..602fa34c9cf --- /dev/null +++ b/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ezo_pmp: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 76508f391e2..12c7b6c8726 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -67,7 +67,7 @@ binary_sensor: return false; # Exercise fan.turn_on with various field combinations so the -# TurnOnAction codegen paths get build coverage. +# register_apply_action codegen paths get build coverage. button: - platform: template name: "Fan Speed Only" @@ -96,6 +96,23 @@ button: - fan.turn_on: id: test_fan speed: !lambda 'return 1;' + - platform: template + name: "Fan Is On Off" + on_press: + - if: + condition: + fan.is_on: test_fan + then: + - fan.turn_off: test_fan + - fan.toggle: test_fan + - fan.turn_off: + id: test_fan + - if: + condition: + fan.is_off: + id: test_fan + then: + - fan.turn_on: test_fan # Exercise fan.turn_on inside triggers with non-empty Ts: # - number.on_value: Ts = float (Python value type; previously raised @@ -111,6 +128,10 @@ number: step: 1 on_value: then: - - fan.turn_on: - id: test_fan - speed: !lambda "return (int) x;" + - if: + condition: + fan.is_on: test_fan + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return (int) x;" diff --git a/tests/components/fingerprint_grow/common-actions.yaml b/tests/components/fingerprint_grow/common-actions.yaml new file mode 100644 index 00000000000..1cecad5875d --- /dev/null +++ b/tests/components/fingerprint_grow/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Fingerprint LED Actions + on_press: + - fingerprint_grow.enroll: 3 + - fingerprint_grow.enroll: + finger_id: !lambda return 4; + num_scans: !lambda return 3; + - fingerprint_grow.led_control: true + - fingerprint_grow.led_control: + state: !lambda return false; + - fingerprint_grow.aura_led_control: + state: BREATHING + speed: 200 + color: BLUE + count: 2 diff --git a/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml b/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..a1e3cf67ede --- /dev/null +++ b/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + sensing_pin: GPIO15 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + fingerprint_grow: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/fujitsu_general/__init__.py b/tests/components/fujitsu_general/__init__.py new file mode 100644 index 00000000000..4ade7998264 --- /dev/null +++ b/tests/components/fujitsu_general/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # This component's AUTO_LOAD = ["climate_ir"] sits on the climate platform manifest, while its + # own __init__.py is empty. The unit test build resolves the bare `fujitsu_general` domain, so + # it never sees that manifest. And climate_ir itself doesn't declare `climate` even though + # ClimateIR derives from climate::Climate. Pull both in so the test can include the header. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/fujitsu_general/fujitsu_general_test.cpp b/tests/components/fujitsu_general/fujitsu_general_test.cpp new file mode 100644 index 00000000000..65b204131a2 --- /dev/null +++ b/tests/components/fujitsu_general/fujitsu_general_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/fujitsu_general/fujitsu_general.h" + +namespace esphome::fujitsu_general::testing { + +// The mode field of a received frame is three bits wide. The fourth bit of the same nibble belongs +// to the clean feature, so it has to be ignored when reading the mode. + +TEST(FujitsuGeneralDecodeModeTest, DecodesTheAssignedModes) { + EXPECT_EQ(decode_mode(0x00, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x01, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x02, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x03, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_EQ(decode_mode(0x04, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, IgnoresTheCleanBit) { + // 0x0B is fan mode with the clean bit set. It used to be read as one value and reported as + // heat/cool, which is the bug this covers. + EXPECT_EQ(decode_mode(0x0B, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + + EXPECT_EQ(decode_mode(0x08, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x09, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0A, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x0C, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, KeepsTheCurrentModeForUnassignedValues) { + // 0x5 to 0x7 fit in the field but the protocol does not use them. + EXPECT_EQ(decode_mode(0x05, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x06, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x07, climate::CLIMATE_MODE_DRY), climate::CLIMATE_MODE_DRY); + + // The same three with the clean bit set. Without the mask these would not reach this branch. + EXPECT_EQ(decode_mode(0x0D, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0E, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x0F, climate::CLIMATE_MODE_FAN_ONLY), climate::CLIMATE_MODE_FAN_ONLY); +} + +TEST(FujitsuGeneralDecodeModeTest, NeverReportsOffForAStateFrame) { + // A state frame describes a running unit, so keeping an off current mode would publish it as off + // and turn the next transmission into a power off command. Automatic is the least specific mode + // available, which is what the field's unassigned values decoded to before they were masked. + for (uint8_t field = 0x05; field <= 0x07; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_mode(field, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(field | 0b1000, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + } +} + +// The fan speed field is three bits wide as well, and used to fold every value it did not +// recognise into the automatic speed. + +TEST(FujitsuGeneralDecodeFanModeTest, DecodesTheAssignedSpeeds) { + EXPECT_EQ(decode_fan_mode(0x00, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x01, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x02, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x03, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x04, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, IgnoresTheFourthBit) { + EXPECT_EQ(decode_fan_mode(0x08, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x09, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0A, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x0B, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0C, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, KeepsTheCurrentFanModeForUnassignedValues) { + EXPECT_EQ(decode_fan_mode(0x05, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x06, climate::CLIMATE_FAN_MEDIUM), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x07, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0D, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0E, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0F, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); +} + +TEST(FujitsuGeneralDecodeFanModeTest, LeavesAnUnsetFanModeUnset) { + EXPECT_FALSE(decode_fan_mode(0x05, {}).has_value()); +} + +// The swing field is only two bits wide. The two bits above it are reserved, and were read as part +// of the value. + +TEST(FujitsuGeneralDecodeSwingModeTest, DecodesTheAssignedValues) { + EXPECT_EQ(decode_swing_mode(0x00), climate::CLIMATE_SWING_OFF); + EXPECT_EQ(decode_swing_mode(0x01), climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(decode_swing_mode(0x02), climate::CLIMATE_SWING_HORIZONTAL); + EXPECT_EQ(decode_swing_mode(0x03), climate::CLIMATE_SWING_BOTH); +} + +TEST(FujitsuGeneralDecodeSwingModeTest, IgnoresTheReservedBits) { + // Without the mask everything from 0x04 up fell through to the default branch and reported swing + // off. All twelve are covered, so the field's whole input space is asserted. + const climate::ClimateSwingMode expected[] = {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}; + for (uint8_t field = 0x04; field <= 0x0F; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_swing_mode(field), expected[field & 0b0011]); + } +} + +// Every state frame annotated in fujitsu_general.h, as the bytes those rows spell out. None of them +// sets the fourth bit of the mode or fan field, or either bit above the swing field, so the masks +// must leave all of them decoding exactly as they did before this change. + +namespace { + +struct CapturedFrame { + const char *label; + uint8_t bytes[16]; + uint8_t temperature; + bool turn_on; + climate::ClimateMode mode; + climate::ClimateFanMode fan_mode; + climate::ClimateSwingMode swing_mode; +}; + +constexpr CapturedFrame CAPTURED_FRAMES[] = { + {"auto auto 18", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x8F}, + 18, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 19", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7F}, + 19, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (temperatures)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"on at 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAB}, + 16, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"down to 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAC}, + 16, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (mode options)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"cool auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCE}, + 30, + true, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"dry auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x02, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCD}, + 30, + true, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"fan (auto) (30)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x03, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCC}, + 30, + true, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCB}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat 30 high", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x01, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_SWING_OFF}, + {"heat 30 med", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x02, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_OFF}, + {"heat 30 low", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x03, 0x00, 0x00, 0x00, 0x20, 0xC9}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_OFF}, + {"heat 30 quiet", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, + {"heat 30 swing vert", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x14, 0x00, 0x00, 0x00, 0x20, 0xB8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_VERTICAL}, + {"heat 30 noswing", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, +}; + +} // namespace + +TEST(FujitsuGeneralCaptureTest, DecodesEveryCapturedFrame) { + for (const auto &frame : CAPTURED_FRAMES) { + SCOPED_TRACE(frame.label); + // Read through the component's own nibble helper and field indices, so this also fails if the + // frame layout the header records ever stops matching what on_receive() reads. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_TEMPERATURE_NIBBLE) + FUJITSU_GENERAL_TEMP_MIN, + frame.temperature); + // The turn on flag is only written by transmit_state(), so this pins the frame layout rather + // than a decode path. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_POWER_ON_NIBBLE) != 0, frame.turn_on); + EXPECT_EQ(decode_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_MODE_NIBBLE), climate::CLIMATE_MODE_OFF), frame.mode); + EXPECT_EQ(decode_fan_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_FAN_NIBBLE), climate::CLIMATE_FAN_ON), + frame.fan_mode); + EXPECT_EQ(decode_swing_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_SWING_NIBBLE)), frame.swing_mode); + } +} + +} // namespace esphome::fujitsu_general::testing diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 6d5721d3be8..bff7de8b7f6 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,8 +4,8 @@ esphome: - globals.set: id: glob_int value: "10" - # Set a float global with an integer literal - must emit the correct - # return type so TemplatableFn stores a direct function pointer. + # Set a float global with an integer literal; the lambda must return the + # global's own type so the assignment needs no conversion. - globals.set: id: glob_float value: "102" diff --git a/tests/components/gree/__init__.py b/tests/components/gree/__init__.py new file mode 100644 index 00000000000..8e15f6d479b --- /dev/null +++ b/tests/components/gree/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/gree/gree_test.cpp b/tests/components/gree/gree_test.cpp new file mode 100644 index 00000000000..9a02c60b39c --- /dev/null +++ b/tests/components/gree/gree_test.cpp @@ -0,0 +1,24 @@ +#include +#include "esphome/components/gree/gree.h" + +namespace esphome::gree::testing { + +TEST(GreeClimateTest, HeatCoolHiddenWithoutHeatByDefault) { + GreeClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.get_traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(GreeClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) { + GreeClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +} // namespace esphome::gree::testing diff --git a/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml new file mode 100644 index 00000000000..9b17da7b9bc --- /dev/null +++ b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml @@ -0,0 +1,10 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false + supports_heat_cool: true diff --git a/tests/components/gree/validate-no-cool.esp32-idf.yaml b/tests/components/gree/validate-no-cool.esp32-idf.yaml new file mode 100644 index 00000000000..3862f302def --- /dev/null +++ b/tests/components/gree/validate-no-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_cool: false diff --git a/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml new file mode 100644 index 00000000000..eea670a696d --- /dev/null +++ b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat_cool: false diff --git a/tests/components/gree/validate-no-heat.esp32-idf.yaml b/tests/components/gree/validate-no-heat.esp32-idf.yaml new file mode 100644 index 00000000000..3c042ebe694 --- /dev/null +++ b/tests/components/gree/validate-no-heat.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false diff --git a/tests/components/grove_tb6612fng/common-actions.yaml b/tests/components/grove_tb6612fng/common-actions.yaml new file mode 100644 index 00000000000..de3537c8f28 --- /dev/null +++ b/tests/components/grove_tb6612fng/common-actions.yaml @@ -0,0 +1,22 @@ +button: + - platform: template + name: Grove Motor Actions + on_press: + - grove_tb6612fng.run: + channel: !lambda return 0; + speed: 100 + direction: FORWARD + id: test_motor + - grove_tb6612fng.stop: + channel: !lambda return 1; + id: test_motor + - grove_tb6612fng.break: + channel: !lambda return 0; + id: test_motor + - grove_tb6612fng.standby: + id: test_motor + - grove_tb6612fng.no_standby: + id: test_motor + - grove_tb6612fng.change_address: + address: 0x15 + id: test_motor diff --git a/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml b/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..f29d189fd58 --- /dev/null +++ b/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + grove_tb6612fng: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/haier/common-actions.yaml b/tests/components/haier/common-actions.yaml new file mode 100644 index 00000000000..11b0902d79b --- /dev/null +++ b/tests/components/haier/common-actions.yaml @@ -0,0 +1,21 @@ +button: + - platform: template + name: Haier Actions + on_press: + - climate.haier.display_on: haier_ac + - climate.haier.display_off: haier_ac + - climate.haier.beeper_on: haier_ac + - climate.haier.beeper_off: haier_ac + - climate.haier.start_self_cleaning: haier_ac + - climate.haier.start_steri_cleaning: haier_ac + - climate.haier.health_on: haier_ac + - climate.haier.health_off: haier_ac + - climate.haier.power_on: haier_ac + - climate.haier.power_off: haier_ac + - climate.haier.power_toggle: haier_ac + - climate.haier.set_vertical_airflow: + id: haier_ac + vertical_airflow: UP + - climate.haier.set_horizontal_airflow: + id: haier_ac + horizontal_airflow: !lambda return esphome::haier::hon_protocol::HorizontalSwingMode::LEFT; diff --git a/tests/components/haier/test-actions.esp8266-ard.yaml b/tests/components/haier/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..079cc9f6393 --- /dev/null +++ b/tests/components/haier/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + haier: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/hbridge/common-actions.yaml b/tests/components/hbridge/common-actions.yaml new file mode 100644 index 00000000000..e0638c49c04 --- /dev/null +++ b/tests/components/hbridge/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: Brake the fan + on_press: + - fan.hbridge.brake: fan_hbridge diff --git a/tests/components/hbridge/test-actions.esp8266-ard.yaml b/tests/components/hbridge/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..752ab72bf9a --- /dev/null +++ b/tests/components/hbridge/test-actions.esp8266-ard.yaml @@ -0,0 +1,10 @@ +substitutions: + pwm_platform: "esp8266_pwm" + output1_pin: "4" + output2_pin: "5" + output3_pin: "12" + output4_pin: "13" + +packages: + hbridge: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/hc8/common-actions.yaml b/tests/components/hc8/common-actions.yaml new file mode 100644 index 00000000000..798ec4cbe6b --- /dev/null +++ b/tests/components/hc8/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: HC8 Calibrate + on_press: + - hc8.calibrate: + id: hc8_sensor + baseline: !lambda return 400; diff --git a/tests/components/hc8/test-actions.esp8266-ard.yaml b/tests/components/hc8/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c19ee22eb39 --- /dev/null +++ b/tests/components/hc8/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + hc8: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/hdc302x/common-actions.yaml b/tests/components/hdc302x/common-actions.yaml new file mode 100644 index 00000000000..9bc3c13312b --- /dev/null +++ b/tests/components/hdc302x/common-actions.yaml @@ -0,0 +1,12 @@ +button: + - platform: template + name: HDC302x Actions + on_press: + - hdc302x.heater_on: + id: hdc302x_sensor + power: 0x03FF + duration: !lambda return 2000; + - hdc302x.heater_on: + id: hdc302x_sensor + power: !lambda return 0x3FFF; + - hdc302x.heater_off: hdc302x_sensor diff --git a/tests/components/hdc302x/test-actions.esp8266-ard.yaml b/tests/components/hdc302x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..01bf8fac7df --- /dev/null +++ b/tests/components/hdc302x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + hdc302x: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/hlk_fm22x/common.yaml b/tests/components/hlk_fm22x/common.yaml index 6fcd9af5949..1371bd8e95c 100644 --- a/tests/components/hlk_fm22x/common.yaml +++ b/tests/components/hlk_fm22x/common.yaml @@ -4,11 +4,18 @@ esphome: - hlk_fm22x.enroll: name: "Test" direction: 1 + - hlk_fm22x.delete: 5 - hlk_fm22x.delete_all: + - hlk_fm22x.reset: + - hlk_fm22x.scan: hlk_fm22x: on_face_scan_matched: - logger.log: test_hlk_22x_face_scan_matched + - hlk_fm22x.enroll: + name: !lambda 'return name + "-again";' + direction: !lambda 'return face_id % 3;' + - hlk_fm22x.delete: !lambda 'return face_id;' on_face_scan_unmatched: - logger.log: test_hlk_22x_face_scan_unmatched on_face_scan_invalid: diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h index a6151697f0f..bc9975f2e8b 100644 --- a/tests/components/hoermann_hcp/common.h +++ b/tests/components/hoermann_hcp/common.h @@ -35,11 +35,18 @@ inline void connect_controller(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } -// Runs one command poll (write 2 / read 8) and returns both key-press registers. -inline std::pair poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +// Runs one status poll (write 2 / read 8) and returns the whole answer. The bus controller writes its counter +// with command 0x03 here; most tests do not care and pass zero. +inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0000) { + door.on_write_registers(COMMAND_REG, make_registers({command_reg, 0x0000})); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); + return response; +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + const RegisterValues response = status_answer(door); EXPECT_EQ(response.size(), 8u); if (response.size() != 8u) return {0xFFFF, 0xFFFF}; @@ -59,7 +66,14 @@ class TestableHoermannHcp : public HoermannHcp { TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } using HoermannHcp::connection_timeout_ms_; +#ifdef USE_HOERMANN_HCP_IDENTITY + using HoermannHcp::identity_asked_at_; + using HoermannHcp::identity_request_; + using HoermannHcp::firmware_unreadable_; + using HoermannHcp::serial_unreadable_; +#endif using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::key_press_delay_ms_; using HoermannHcp::light_toggle_released_at_; using HoermannHcp::light_toggles_in_flight_; using HoermannHcp::set_valid_; diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 618a8181bfb..21d3d2b8485 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -22,3 +22,12 @@ button: light: - platform: hoermann_hcp name: Garage Light + +text_sensor: + - platform: hoermann_hcp + door_state: + name: Garage Door State + serial_number: + name: Garage Motor Serial Number + version: + name: Garage Motor Firmware Version diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp index ed7e81b2797..75b44a3af6e 100644 --- a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -24,14 +24,17 @@ class CountingHoermannHcpLight : public HoermannHcpLight { int writes{0}; }; -// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +// Drives the platform against a real LightState. Boots off (no persistence) by default, which +// keeps setup() clear of preferences. struct LightFixture { TestableHoermannHcp door; CountingHoermannHcpLight output{&door}; light::LightState state{&output}; - explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { - this->state.set_restore_mode(restore_mode); + explicit LightFixture(bool boot_on = false) { + if (boot_on) { + this->state.set_state_callback([](light::LightStateRTCState &s, bool /*restored*/) { s.state = true; }); + } this->output.setup(); // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the // boot ordering tests need to be able to place around the bus controller coming up. @@ -621,10 +624,10 @@ TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarme EXPECT_EQ(door.light_toggle_released_at_, 0u); } -// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// Booting the entity on replays a lit state the door has never confirmed, so it has to be // adopted back to what is known rather than turned into a command. TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { - LightFixture fixture{light::LIGHT_ALWAYS_ON}; + LightFixture fixture{/*boot_on=*/true}; connect_controller(fixture.door); fixture.settle(); diff --git a/tests/components/hoermann_hcp/text_sensor/__init__.py b/tests/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 00000000000..20e71245071 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_HOERMANN_HCP_IDENTITY") + + manifest.to_code = to_code_testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp new file mode 100644 index 00000000000..dfb3ba0e92d --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp @@ -0,0 +1,125 @@ +#include + +#include "esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// A status broadcast with the door state in the high byte of its third register. +void broadcast_state(HoermannHcp &door, uint16_t state_reg) { + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, state_reg})); + door.update(); +} + +struct DoorStateFixture { + DoorStateFixture() { + this->sensor.setup(); + this->sensor.add_on_state_callback([this](const std::string & /*state*/) { this->publishes++; }); + } + + TestableHoermannHcp door; + HoermannHcpDoorStateTextSensor sensor{&door}; + int publishes{0}; +}; + +} // namespace + +// Polls and bus scans make the connection valid before any broadcast has said where the door is, and an +// undecodable state says nothing either. None of these may show the default state. +TEST(HoermannHcpDoorStateTest, NothingBeforeTheDoorReportsAState) { + DoorStateFixture fixture; + auto &door = fixture.door; + door.update(); + EXPECT_FALSE(fixture.sensor.has_state()); + + status_answer(door, 0x0003); + door.update(); + ASSERT_TRUE(door.is_valid()); + EXPECT_FALSE(fixture.sensor.has_state()); + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + door.update(); + EXPECT_FALSE(fixture.sensor.has_state()); + + broadcast_state(door, 0x1000); + EXPECT_FALSE(fixture.sensor.has_state()); + + // The first real state is shown even when it equals the default. + broadcast_state(door, 0x4000); + EXPECT_EQ(fixture.sensor.get_state(), "Closed"); +} + +// Every state the door reports is shown, including the vent and half-open positions and the moves to them. +TEST(HoermannHcpDoorStateTest, FollowsTheDoorState) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + + const std::pair states[] = { + {0x2000, "Open"}, {0x0200, "Closing"}, {0x4000, "Closed"}, {0x0900, "Moving to vent"}, + {0x0A00, "Vent position"}, {0x0500, "Moving to half"}, {0x8000, "Half open"}, {0x0100, "Opening"}, + {0x0000, "Stopped"}, {0x0061, "Vent position"}, + }; + for (const auto &[reg, text] : states) { + broadcast_state(door, reg); + EXPECT_EQ(fixture.sensor.get_state(), text) << "state register 0x" << std::hex << reg; + } +} + +// Any hub change runs the publish path, so an unchanged door state is not published again, and a state the +// door is not known to report keeps the last one. +TEST(HoermannHcpDoorStateTest, EachStateIsPublishedOnce) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + broadcast_state(door, 0x2000); + ASSERT_EQ(fixture.publishes, 1); + + // The lamp changes, the door state does not. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x2000, 0x0000, 0x0000, 0x0000, 0x0010})); + door.update(); + EXPECT_EQ(fixture.publishes, 1); + + broadcast_state(door, 0x1000); + EXPECT_EQ(fixture.publishes, 1); + EXPECT_EQ(fixture.sensor.get_state(), "Open"); +} + +// While the bus controller is gone the last state stays. Once it is back, a poll alone shows nothing new; the +// next broadcast is published again even if it repeats the old state. +TEST(HoermannHcpDoorStateTest, LastStateStaysUntilTheNextBroadcast) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + broadcast_state(door, 0x8000); + ASSERT_EQ(fixture.publishes, 1); + + door.set_valid_(false); + door.update(); + EXPECT_EQ(fixture.sensor.get_state(), "Half open"); + + connect_controller(door); + door.update(); + EXPECT_EQ(fixture.publishes, 1); + + broadcast_state(door, 0x8000); + EXPECT_EQ(fixture.publishes, 2); + EXPECT_EQ(fixture.sensor.get_state(), "Half open"); +} + +// A sensor set up after the hub already decoded a state shows it right away. +TEST(HoermannHcpDoorStateTest, LateSetupShowsTheCurrentState) { + TestableHoermannHcp door; + connect_controller(door); + broadcast_state(door, 0x0A00); + + HoermannHcpDoorStateTextSensor sensor(&door); + sensor.setup(); + EXPECT_EQ(sensor.get_state(), "Vent position"); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp new file mode 100644 index 00000000000..8dff56e2a36 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp @@ -0,0 +1,510 @@ +#include +#include + +#include +#include + +#include "esphome/components/text_sensor/text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Made up. 26 bytes on the wire: the first 14 arrive in one transfer, the other 12 in the next. +constexpr const char *SERIAL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +// Made up as well, padded with a space to the 12 bytes the motor sends. +constexpr const char *FIRMWARE = "FW-TEST 1.0 "; + +constexpr uint8_t SUB_SERIAL = 0x0C; +constexpr uint8_t SUB_FIRMWARE = 0x0D; +constexpr uint8_t FIRST_HALF = 0x80; + +// A status poll as the bus controller writes it: its counter in the high byte, command 0x03 in the low. +RegisterValues status_poll(HoermannHcp &door, uint8_t counter = 0x05) { + return status_answer(door, static_cast((counter << 8) | 0x03)); +} + +// The write half of a payload transfer: the motor writes the bytes into the command block. +void write_transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len) { + RegisterValues written; + written.push_back(static_cast((counter << 8) | 0x04)); + written.push_back(static_cast(sub_code << 8)); + for (size_t i = 0; i < len; i += 2) { + written.push_back( + static_cast((static_cast(bytes[i]) << 8) | static_cast(bytes[i + 1]))); + } + door.on_write_registers(COMMAND_REG, written); +} + +// A whole payload transfer, returning the answer the motor reads back. +RegisterValues transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len, + uint16_t read_registers = 8) { + write_transfer(door, counter, sub_code, bytes, len); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, read_registers, response); + return response; +} + +// The first status poll gets an ordinary answer, the next one carries the serial number request. +void request_serial(HoermannHcp &door) { + status_poll(door, 0x03); + status_poll(door, 0x04); +} + +void send_serial(HoermannHcp &door) { + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); +} + +// A hub with both sensors configured, which is what makes it ask. +struct IdentityFixture { + IdentityFixture() { + this->door.set_serial_number_text_sensor(&this->serial); + this->door.set_version_text_sensor(&this->version); + } + // What the sensors show once the loop has turned; empty while they have no state. + std::string serial_shown() { + this->door.update(); + return this->serial.has_state() ? this->serial.get_state() : ""; + } + std::string version_shown() { + this->door.update(); + return this->version.has_state() ? this->version.get_state() : ""; + } + + TestableHoermannHcp door; + text_sensor::TextSensor serial; + text_sensor::TextSensor version; +}; + +// The whole exchange as the motor runs it, with the loop turning in between as it would. +void run_identity_exchange(HoermannHcp &door) { + request_serial(door); + send_serial(door); + door.update(); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + door.update(); +} + +} // namespace + +// With no sensor configured, polls and transfers are answered exactly as before. +TEST(HoermannHcpTextSensorTest, NothingChangesWithoutASensor) { + HoermannHcp door; + for (int poll = 0; poll < 2; poll++) { + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0301); + EXPECT_EQ(response[2], 0x0000); + } + const RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + EXPECT_EQ(answer[1] & 0x00FF, 0x0001); +} + +// Like Hoermann's own bus accessory, the first status poll gets an ordinary answer and the next one carries the +// request, echoing the status command like any status answer. It is asked again only once 30 s have passed. +TEST(HoermannHcpTextSensorTest, SerialNumberIsAskedForAfterOneOrdinaryAnswer) { + IdentityFixture fixture; + auto &door = fixture.door; + EXPECT_EQ(status_poll(door)[1], 0x0301); + + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[0], 0x0500); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + + door.identity_asked_at_ -= 29000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + door.identity_asked_at_ -= 2000; + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Three attempts at the serial number, then the firmware version is asked for anyway, three times as well. +TEST(HoermannHcpTextSensorTest, GivesUpAfterThreeAttemptsEach) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A first half left behind by a serial number that never completed is not shown. +TEST(HoermannHcpTextSensorTest, HalfASerialNumberIsNeverShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + for (int attempt = 0; attempt < 4; attempt++) { + door.identity_asked_at_ -= 31000; + status_poll(door); + } + EXPECT_EQ(fixture.serial_shown(), ""); +} + +// Each half is acknowledged with the counter it came with, minus the half marker. The serial number is shown as +// soon as it is whole, and the firmware version is asked for right after. +TEST(HoermannHcpTextSensorTest, SerialNumberInTwoHalvesThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + + RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + ASSERT_EQ(answer.size(), 8u); + EXPECT_EQ(answer[0], 0x0500); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), ""); + + answer = transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(answer[0], 0x0600); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + EXPECT_EQ(fixture.version_shown(), ""); + + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + + answer = transfer(door, 0x07, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// Once both values are in, nothing more is asked, however long the device keeps running. +TEST(HoermannHcpTextSensorTest, FinishedExchangeStaysFinished) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + + door.identity_asked_at_ -= 31000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// The text ends at the first byte that is not printable. 0xFF is below the printable range where char is signed, +// as on the host, and above it where char is unsigned, as on most targets. DEL is above it either way. +TEST(HoermannHcpTextSensorTest, PaddingEndsTheText) { + for (const char pad : {'\xFF', '\x7F'}) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char first[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', pad}; + const char second[] = {pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad}; + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, first, sizeof(first)); + transfer(door, 0x06, SUB_SERIAL, second, sizeof(second)); + EXPECT_EQ(fixture.serial_shown(), "1234567890123"); + } +} + +// A half that cannot be used is still acknowledged but not kept, so the request stays open for the retry: a +// first half too short, a second half too short, a second half without a first. +TEST(HoermannHcpTextSensorTest, UnusableSerialHalvesAreNotKept) { + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12)[1], 0x04FD); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 10); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } +} + +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. The bytes are +// the ones a B1 sent, with the serial number made up. +TEST(HoermannHcpTextSensorTest, SerialNumberInOneFrameThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x05, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + auto answer = status_poll(door, 0x06); + EXPECT_EQ(answer[1], 0x0322); + EXPECT_EQ(answer[2], 0x0600); +} + +// The frames as a B1 motor sends them on the bus, which reads a transfer answer back as 2 registers. The serial +// number is made up. +TEST(HoermannHcpTextSensorTest, ExchangeWithTheFrameSizesOfAB1) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_THAT(transfer(door, 0x05, SUB_SERIAL, one_frame, 12, 2), ::testing::ElementsAre(0x0500, 0x04FD)); + + EXPECT_THAT(status_poll(door, 0x06), ::testing::ElementsAre(0x0600, 0x0322, 0x0600, 0, 0, 0, 0, 0)); + const char zeros[12] = {}; + EXPECT_THAT(transfer(door, 0x07, SUB_FIRMWARE, zeros, 12, 2), ::testing::ElementsAre(0x0700, 0x04FD)); + + EXPECT_EQ(door.identity_request_(), 0); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_EQ(status_poll(door, 0x08)[1], 0x0301); +} + +// A transfer of the value not asked for is acknowledged but not kept, and the request stays open. +TEST(HoermannHcpTextSensorTest, TheValueNotAskedForIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, 0x05, SUB_FIRMWARE, FIRMWARE, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.version_shown(), ""); + + send_serial(door); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + status_poll(door, 0x07); + const char other[14] = {'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z'}; + EXPECT_EQ(transfer(door, FIRST_HALF | 0x08, SUB_SERIAL, other, 14)[1], 0x04FD); + EXPECT_EQ(transfer(door, 0x09, SUB_SERIAL, other, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), SERIAL); +} + +// A transfer before the request has gone out, as from a motor still finishing an exchange from before a restart, +// is acknowledged but not kept. +TEST(HoermannHcpTextSensorTest, ATransferBeforeTheRequestIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x04, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(status_poll(door, 0x05)[1], 0x0322); +} + +// After a frame with the half marker, one without it can only be the second half, even if the first was unusable. +TEST(HoermannHcpTextSensorTest, ASecondHalfIsNeverTakenForTheWholeNumber) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); +} + +// A serial number without any text at its start is not shown but logged, in one frame or in two halves. The +// firmware version is still asked for. +TEST(HoermannHcpTextSensorTest, SerialNumberThatIsNotTextIsLoggedNotShown) { + const char zeros[14] = {}; + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, 0x05, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); // kept for the log + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); // logged once + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, zeros, 14); + transfer(door, 0x06, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); + } +} + +// A firmware version too short is not kept, and is asked for again. +TEST(HoermannHcpTextSensorTest, ShortFirmwareVersionIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + EXPECT_TRUE(door.firmware_unreadable_); // kept for the log + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); // logged once + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A firmware version without any payload is logged, not shown. +TEST(HoermannHcpTextSensorTest, EmptyFirmwareVersionIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 0); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A readable firmware version right after a short one, before the loop has turned, is still shown. +TEST(HoermannHcpTextSensorTest, ReadableFirmwareVersionAfterAShortOneIsShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + transfer(door, 0x09, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// All zeros is how a motor that does not report its version says so: nothing shown, not asked for again. +TEST(HoermannHcpTextSensorTest, AllZeroFirmwareVersionMeansNoneIsReported) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char zeros[12] = {}; + transfer(door, 0x08, SUB_FIRMWARE, zeros, 12); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A firmware version that is not text is not shown, is logged, and is not asked for again: it would come back +// the same. +TEST(HoermannHcpTextSensorTest, FirmwareVersionThatIsNotTextIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char binary[12] = {0x01, 0x12, 0x34, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}; + transfer(door, 0x08, SUB_FIRMWARE, binary, 12); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); // the raw bytes are not published + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A repeat of a transfer already taken, as after a lost acknowledgement, is acknowledged again. Answered as a +// status poll instead, it would carry the key press waiting in the slot. +TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithAKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + connect_controller(door); + door.open_door(); + + const RegisterValues answer = transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(answer[2], 0x0000); + EXPECT_EQ(status_poll(door)[2], 0x0210); +} + +// An answer belongs to the frame whose write half took the transfer. A frame whose read went elsewhere leaves +// nothing behind for the next poll. +TEST(HoermannHcpTextSensorTest, AnAnswerBelongsToItsFrame) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + write_transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + RegisterValues ignored; + door.on_read_holding_registers(COMMAND_REG, 8, ignored); + + EXPECT_EQ(status_poll(door)[1] & 0x00FF, 0x0022); +} + +// Only the answer to a status poll carries a request, not the answer to another frame of the same length, and +// other transfers are not this exchange's to answer. +TEST(HoermannHcpTextSensorTest, RequestRidesOnlyOnAStatusPoll) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2); + EXPECT_EQ(other[1] & 0x00FF, 0x0001); + EXPECT_EQ(status_poll(door, 0x07)[1], 0x0322); +} + +// The request travels in the registers a key press would, so it waits for the press, the hold and the release. +TEST(HoermannHcpTextSensorTest, RequestWaitsForTheKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + door.key_press_delay_ms_ = 100; + connect_controller(door); + status_poll(door); + door.open_door(); + + EXPECT_EQ(status_poll(door)[2], 0x0210); + const RegisterValues held = status_poll(door); + EXPECT_EQ(held[1], 0x0301); + EXPECT_EQ(held[2], 0x0000); + door.key_press_delay_ms_ = 0; + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + const RegisterValues release = status_poll(door); + EXPECT_EQ(release[1], 0x0301); + EXPECT_EQ(release[2], 0x0110); + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Each value is published once it is in, and only once. +TEST(HoermannHcpTextSensorTest, EachValueIsPublishedOnce) { + IdentityFixture fixture; + int serial_publishes = 0; + int version_publishes = 0; + fixture.serial.add_on_state_callback([&serial_publishes](const std::string & /*state*/) { serial_publishes++; }); + fixture.version.add_on_state_callback([&version_publishes](const std::string & /*state*/) { version_publishes++; }); + + fixture.door.update(); + EXPECT_EQ(serial_publishes, 0); + EXPECT_EQ(version_publishes, 0); + + run_identity_exchange(fixture.door); + EXPECT_EQ(fixture.serial.get_state(), SERIAL); + EXPECT_EQ(fixture.version.get_state(), "FW-TEST 1.0"); + + fixture.door.update(); + fixture.door.update(); + EXPECT_EQ(serial_publishes, 1); + EXPECT_EQ(version_publishes, 1); +} + +// Configuring only one of the two is enough to ask. +TEST(HoermannHcpTextSensorTest, OneSensorIsEnough) { + TestableHoermannHcp version_only; + text_sensor::TextSensor version; + version_only.set_version_text_sensor(&version); + run_identity_exchange(version_only); + EXPECT_EQ(version.get_state(), "FW-TEST 1.0"); + + TestableHoermannHcp serial_only; + text_sensor::TextSensor serial; + serial_only.set_serial_number_text_sensor(&serial); + run_identity_exchange(serial_only); + EXPECT_EQ(serial.get_state(), SERIAL); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 4b3c2ca36bc..eb8a6d33e7a 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -50,11 +50,11 @@ esphome: format: "After delay, body still: %s" args: - body.c_str() - # Regression test for esphome/esphome#16224: a LightControlAction + # Regression test for esphome/esphome#16224: a light.turn_on action # nested inside on_response with capture_response: true puts # `std::string &` into the trigger's Ts..., which exposed a codegen - # bug where the apply lambda's parameter list did not match the - # ApplyFn signature. + # bug where the generated function's parameter list did not match + # the automation's arguments. - light.turn_on: id: test_regression_light brightness: 100% diff --git a/tests/components/http_request/test.esp32-c6-idf.yaml b/tests/components/http_request/test.esp32-c6-idf.yaml new file mode 100644 index 00000000000..ee2f5aa59b8 --- /dev/null +++ b/tests/components/http_request/test.esp32-c6-idf.yaml @@ -0,0 +1,4 @@ +substitutions: + verify_ssl: "true" + +<<: !include common.yaml diff --git a/tests/components/icnt86/common.yaml b/tests/components/icnt86/common.yaml new file mode 100644 index 00000000000..1537bb8b762 --- /dev/null +++ b/tests/components/icnt86/common.yaml @@ -0,0 +1,24 @@ +touchscreen: + - platform: icnt86 + i2c_id: i2c_bus + interrupt_pin: ${interrupt_pin_touch} + reset_pin: ${reset_pin_touch} + display: epaper + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] + +display: + - platform: waveshare_epaper + id: epaper + rotation: 90 + cs_pin: ${cs_pin_display} + dc_pin: ${dc_pin_display} + busy_pin: ${busy_pin_display} + reset_pin: ${reset_pin_display} + model: 2.90inv2-r2 + pages: + - id: icnt86_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/icnt86/test.esp32-idf.yaml b/tests/components/icnt86/test.esp32-idf.yaml new file mode 100644 index 00000000000..a0b882292a5 --- /dev/null +++ b/tests/components/icnt86/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + interrupt_pin_touch: GPIO4 + reset_pin_touch: GPIO32 + cs_pin_display: GPIO33 + dc_pin_display: GPIO21 + busy_pin_display: GPIO27 + reset_pin_display: GPIO14 + clk_pin: GPIO25 + mosi_pin: GPIO26 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icnt86: !include common.yaml diff --git a/tests/components/improv_base/rpc_response_builder_test.cpp b/tests/components/improv_base/rpc_response_builder_test.cpp index d9d0ad90d88..f7f0eda38c4 100644 --- a/tests/components/improv_base/rpc_response_builder_test.cpp +++ b/tests/components/improv_base/rpc_response_builder_test.cpp @@ -52,7 +52,7 @@ TEST(RpcResponseBuilder, GoldenBytes) { (std::vector{0x04, 0x03, 0x02, 'a', 'b', 0xCC})); } -// esp32_improv calls finish() and build_rpc_response() with no checksum flag, +// improv_ble calls finish() and build_rpc_response() with no checksum flag, // so the two defaults must agree TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) { const std::vector urls = {"https://example.com"}; diff --git a/tests/components/esp32_improv/common.yaml b/tests/components/improv_ble/common.yaml similarity index 96% rename from tests/components/esp32_improv/common.yaml rename to tests/components/improv_ble/common.yaml index 7dc2f7b6c73..7605cd6e657 100644 --- a/tests/components/esp32_improv/common.yaml +++ b/tests/components/improv_ble/common.yaml @@ -12,7 +12,7 @@ output: pin: 2 id: built_in_led -esp32_improv: +improv_ble: authorizer: io0_button authorized_duration: 1min status_indicator: built_in_led diff --git a/tests/components/esp32_improv/test.esp32-c3-idf.yaml b/tests/components/improv_ble/test.esp32-c3-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-c3-idf.yaml rename to tests/components/improv_ble/test.esp32-c3-idf.yaml diff --git a/tests/components/esp32_improv/test.esp32-idf.yaml b/tests/components/improv_ble/test.esp32-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-idf.yaml rename to tests/components/improv_ble/test.esp32-idf.yaml diff --git a/tests/components/improv_serial/common-uart0.yaml b/tests/components/improv_serial/common-uart0.yaml index 45bf1e5c330..3710cb3bb53 100644 --- a/tests/components/improv_serial/common-uart0.yaml +++ b/tests/components/improv_serial/common-uart0.yaml @@ -5,6 +5,6 @@ wifi: logger: hardware_uart: UART0 -# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_ +# next_url compiles the USE_IMPROV_NEXT_URL branch and add_next_url_ improv_serial: next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}} diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml index c362f7f28c4..922e7d0ed3c 100644 --- a/tests/components/it8951/test.esp32-s3-idf.yaml +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -59,6 +59,7 @@ display: - platform: it8951 spi_id: spi_bus model: seeed-reterminal-e1003 + update_mode: DEFAULT cs_pin: allow_other_uses: true number: GPIO5 diff --git a/tests/components/json/__init__.py b/tests/components/json/__init__.py index 40ec1f996ea..37e4dba021b 100644 --- a/tests/components/json/__init__.py +++ b/tests/components/json/__init__.py @@ -1,3 +1,6 @@ +import functools + +from esphome.components.json import enable_arena from tests.testing_helpers import ComponentManifestOverride @@ -7,3 +10,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # library registration to happen, otherwise json_util.cpp fails to find # ArduinoJson.h. manifest.enable_codegen() + # The JsonArena host test needs the arena compiled in, as a consumer would request it + real_to_code = manifest.to_code + + @functools.wraps(real_to_code) + async def to_code_with_arena(config): + await real_to_code(config) + enable_arena() + + manifest.to_code = to_code_with_arena diff --git a/tests/components/json/test_json_arena.cpp b/tests/components/json/test_json_arena.cpp new file mode 100644 index 00000000000..1d42997225e --- /dev/null +++ b/tests/components/json/test_json_arena.cpp @@ -0,0 +1,238 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/json/json_util.h" + +using esphome::json::JsonArena; +using esphome::json::JsonBuilder; + +namespace { + +constexpr size_t ALIGN = alignof(std::max_align_t); +constexpr size_t round_up(size_t n) { return (n + ALIGN - 1) & ~(ALIGN - 1); } + +// Counts what the arena could not hold +struct Counting final : ArduinoJson::Allocator { + int allocs{0}; + void *allocate(size_t n) override { + this->allocs++; + return malloc(n); // NOLINT + } + void deallocate(void *p) override { free(p); } // NOLINT + void *reallocate(void *p, size_t n) override { + this->allocs++; + return realloc(p, n); // NOLINT + } +}; + +// Refuses everything, so a spill or a move sees the heap as exhausted +struct NoMemory final : ArduinoJson::Allocator { + void *allocate(size_t) override { return nullptr; } + void deallocate(void *) override {} + void *reallocate(void *, size_t) override { return nullptr; } +}; + +template bool inside(const JsonArena &arena, const void *p) { + auto base = reinterpret_cast(&arena); + auto addr = reinterpret_cast(p); + return addr >= base && addr < base + sizeof(arena); +} + +} // namespace + +TEST(JsonArena, BumpsAlignedInsideTheBuffer) { + JsonArena<256> arena; + auto *a = static_cast(arena.allocate(10)); + auto *b = static_cast(arena.allocate(10)); + ASSERT_NE(a, nullptr); + ASSERT_NE(b, nullptr); + EXPECT_TRUE(inside(arena, a)); + EXPECT_TRUE(inside(arena, b)); + EXPECT_EQ(reinterpret_cast(a) % ALIGN, 0u); + EXPECT_EQ(static_cast(b - a), round_up(10)); + arena.deallocate(a); + arena.deallocate(b); +} + +TEST(JsonArena, SpillsToTheHeapWhenFull) { + JsonArena<64> arena; + void *a = arena.allocate(48); + void *b = arena.allocate(48); + ASSERT_NE(a, nullptr); + ASSERT_NE(b, nullptr); + EXPECT_TRUE(inside(arena, a)); + EXPECT_FALSE(inside(arena, b)); + std::memset(b, 'b', 48); + arena.deallocate(b); // routed to the heap; a mismatch would trip the sanitizer + arena.deallocate(a); +} + +TEST(JsonArena, NewestBlockGrowsAndShrinksInPlace) { + JsonArena<256> arena; + void *a = arena.allocate(16); + std::memset(a, 'x', 16); + EXPECT_EQ(arena.reallocate(a, 96), a); + EXPECT_EQ(std::memcmp(a, "xxxxxxxxxxxxxxxx", 16), 0); + EXPECT_EQ(arena.reallocate(a, 8), a); + auto *next = static_cast(arena.allocate(8)); + EXPECT_EQ(static_cast(next - static_cast(a)), round_up(8)); +} + +TEST(JsonArena, NewestBlockMovesToTheHeapAndFreesItsSpace) { + JsonArena<64> arena; + void *a = arena.allocate(32); + std::memset(a, 'q', 32); + void *moved = arena.reallocate(a, 200); + ASSERT_NE(moved, nullptr); + EXPECT_FALSE(inside(arena, moved)); + EXPECT_EQ(std::memcmp(moved, "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", 32), 0); + EXPECT_EQ(arena.allocate(16), a); // the space it left is handed out again + arena.deallocate(moved); +} + +TEST(JsonArena, OlderBlockMovesToTheHeapKeepingItsBytes) { + JsonArena<256> arena; + void *a = arena.allocate(16); + std::memset(a, 'a', 16); + auto *b = static_cast(arena.allocate(16)); + void *moved = arena.reallocate(a, 64); + ASSERT_NE(moved, nullptr); + EXPECT_FALSE(inside(arena, moved)); + EXPECT_EQ(std::memcmp(moved, "aaaaaaaaaaaaaaaa", 16), 0); + auto *next = static_cast(arena.allocate(8)); + EXPECT_EQ(static_cast(next - b), round_up(16)); // b's space is untouched + arena.deallocate(moved); +} + +TEST(JsonArena, HeapBlocksReallocateOnTheHeap) { + JsonArena<32> arena; + void *a = arena.allocate(64); // never fit + EXPECT_FALSE(inside(arena, a)); + std::memset(a, 'h', 64); + void *grown = arena.reallocate(a, 128); + ASSERT_NE(grown, nullptr); + EXPECT_EQ(std::memcmp(grown, "hhhhhhhhhhhhhhhh", 16), 0); + arena.deallocate(grown); +} + +TEST(JsonArena, FailedMoveKeepsTheBlockReserved) { + NoMemory no_memory; + JsonArena<64> arena(&no_memory); + void *a = arena.allocate(32); + std::memset(a, 'k', 32); + EXPECT_EQ(arena.reallocate(a, 200), nullptr); + EXPECT_EQ(std::memcmp(a, "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk", 32), 0); + auto *b = static_cast(arena.allocate(16)); // must not hand out a's bytes again + ASSERT_NE(b, nullptr); + EXPECT_EQ(static_cast(b - static_cast(a)), round_up(32)); + EXPECT_EQ(arena.allocate(64), nullptr); // nothing left and the fallback refuses +} + +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +constexpr size_t ARENA = esphome::json::JSON_ARENA_SIZE; + +// The documents the event stream sends must fit without touching the fallback, and their copied +// strings must land in the headroom above the pool +TEST(JsonArena, StateDocumentsFitWithoutTouchingTheFallback) { + Counting counting; + JsonArena arena(&counting); + { + // A switch state event: copied id, domain and name, bool value + JsonBuilder builder(&arena); + JsonObject root = builder.root(); + char id_buf[] = "switch/SSE Toggle"; + char domain_buf[] = "switch"; + char name_buf[] = "SSE Toggle"; + root["id"] = static_cast(id_buf); + root["domain"] = static_cast(domain_buf); + root["name"] = static_cast(name_buf); + root["icon"] = ""; + root["entity_category"] = 0; + root["value"] = true; + root["state"] = "ON"; + root["assumed_state"] = false; + char out[256]; + EXPECT_LT(builder.serialize_to(out, sizeof(out)), sizeof(out)); + } + EXPECT_EQ(counting.allocs, 0); + EXPECT_GT(arena.used(), esphome::json::JSON_POOL_BYTES); + + Counting counting_select; + JsonArena select_arena(&counting_select); + { + // A 40 option select detail document: copied id, domain, name and value, linked options + JsonBuilder builder(&select_arena); + JsonObject root = builder.root(); + char id_buf[] = "select/SSE Big Select"; + char domain_buf[] = "select"; + char name_buf[] = "SSE Big Select"; + char value_buf[] = "option number 17 padded to twenty"; + root["id"] = static_cast(id_buf); + root["domain"] = static_cast(domain_buf); + root["name"] = static_cast(name_buf); + root["icon"] = ""; + root["entity_category"] = 0; + root["value"] = static_cast(value_buf); + root["state"] = static_cast(value_buf); + JsonArray options = root["option"].to(); + char option_bufs[40][44]; // room for any int, so -Wformat-truncation stays quiet + for (int i = 0; i < 40; i++) { + snprintf(option_bufs[i], sizeof(option_bufs[i]), "option number %02d padded to twenty", i); + options.add(JsonString(option_bufs[i], true)); + } + char out[2048]; + EXPECT_LT(builder.serialize_to(out, sizeof(out)), sizeof(out)); + } + EXPECT_EQ(counting_select.allocs, 0); + EXPECT_GT(select_arena.used(), esphome::json::JSON_POOL_BYTES); + + // The same select with its 40 options copied, as the generator does before the strings are + // linked, does not fit: the headroom is sized for linked options and the rest spills + Counting counting_copied; + JsonArena copied_arena(&counting_copied); + { + JsonBuilder builder(&copied_arena); + JsonArray options = builder.root()["option"].to(); + char option_bufs[40][44]; // room for any int, so -Wformat-truncation stays quiet + for (int i = 0; i < 40; i++) { + snprintf(option_bufs[i], sizeof(option_bufs[i]), "option number %02d padded to twenty", i); + options.add(static_cast(option_bufs[i])); + } + char out[2048]; + EXPECT_LT(builder.serialize_to(out, sizeof(out)), sizeof(out)); + } + EXPECT_GT(counting_copied.allocs, 0); + EXPECT_GT(copied_arena.used(), ARENA - 64); // the arena filled up before the spill began +} + +TEST(JsonArena, DocumentMatchesTheHeapAllocator) { + // 700 integers need six pools, which also grows ArduinoJson's pool list past its preallocated four + auto build = [](JsonBuilder &builder) { + JsonArray arr = builder.root()["a"].to(); + for (int i = 0; i < 700; i++) { + arr.add(i); + } + JsonArray strings = builder.root()["s"].to(); + char buf[40]; // room for any int, so -Wformat-truncation stays quiet + for (int i = 0; i < 60; i++) { + snprintf(buf, sizeof(buf), "string number %04d padded", i); + strings.add(buf); + } + }; + JsonArena arena; + JsonBuilder with_arena(&arena); + build(with_arena); + JsonBuilder with_heap; + build(with_heap); + std::string a = with_arena.serialize(); + std::string b = with_heap.serialize(); + EXPECT_GT(a.size(), 4000u); + EXPECT_EQ(a, b); +} +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) diff --git a/tests/components/lcd_menu/common-actions.yaml b/tests/components/lcd_menu/common-actions.yaml new file mode 100644 index 00000000000..9c697a7a363 --- /dev/null +++ b/tests/components/lcd_menu/common-actions.yaml @@ -0,0 +1,17 @@ +button: + - platform: template + name: Menu Actions + on_press: + - display_menu.show: test_lcd_menu + - display_menu.up: test_lcd_menu + - display_menu.down: test_lcd_menu + - display_menu.left: test_lcd_menu + - display_menu.right: test_lcd_menu + - display_menu.enter: test_lcd_menu + - if: + condition: + display_menu.is_active: test_lcd_menu + then: + - display_menu.hide: test_lcd_menu + else: + - display_menu.show_main: test_lcd_menu diff --git a/tests/components/lcd_menu/test-actions.esp8266-ard.yaml b/tests/components/lcd_menu/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..49b48563c3a --- /dev/null +++ b/tests/components/lcd_menu/test-actions.esp8266-ard.yaml @@ -0,0 +1,11 @@ +substitutions: + d0_pin: GPIO0 + d1_pin: GPIO2 + d2_pin: GPIO14 + d3_pin: GPIO15 + enable_pin: GPIO16 + rs_pin: GPIO5 + +packages: + lcd_menu: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/ld2410/common-actions.yaml b/tests/components/ld2410/common-actions.yaml new file mode 100644 index 00000000000..7cd4d6abbc0 --- /dev/null +++ b/tests/components/ld2410/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: LD2410 Actions + on_press: + - bluetooth_password.set: + id: my_ld2410 + password: HiLink + - bluetooth_password.set: + id: my_ld2410 + password: !lambda return "HiLink"; diff --git a/tests/components/ld2410/test-actions.esp8266-ard.yaml b/tests/components/ld2410/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..188d8cb87fc --- /dev/null +++ b/tests/components/ld2410/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ld2410: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/ledc/common.yaml b/tests/components/ledc/common.yaml index 70352b45195..52bef63fbb6 100644 --- a/tests/components/ledc/common.yaml +++ b/tests/components/ledc/common.yaml @@ -4,6 +4,9 @@ esphome: - output.ledc.set_frequency: id: test_ledc frequency: 100Hz + - output.ledc.set_frequency: + id: test_ledc + frequency: !lambda return 200.0f; output: - platform: ledc diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py new file mode 100644 index 00000000000..2d5eb9223dc --- /dev/null +++ b/tests/components/light/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Enables light_json_schema.cpp without USE_MQTT, which pulls mqtt code into core/util.cpp + async def to_code_testing(config): + cg.add_define("USE_WEBSERVER") + # api_connection.cpp reports the port whenever USE_WEBSERVER is set + cg.add_define("USE_WEBSERVER_PORT", 80) + # The gamma table and its stored gamma are behind this define + cg.add_define("USE_LIGHT_GAMMA_LUT") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f103..c40cfd5e3a3 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -68,15 +68,42 @@ esphome: - light.toggle: test_binary_light - light.turn_off: test_rgb_light + - if: + condition: + light.is_on: test_binary_light + then: + - logger.log: binary light is on + - if: + condition: + light.is_off: + id: test_rgb_light + then: + - logger.log: rgb light is off - light.turn_on: id: test_rgb_light brightness: 100% red: 100% green: 100% blue: 1.0 + - light.turn_on: + id: test_rgb_light + color: Tomato + - light.turn_on: + id: test_rgb_light + color: 0x223344 - light.control: id: test_monochromatic_light state: on + # flash_length and a turn_off with its own transition + - light.turn_on: + id: test_rgb_light + flash_length: 750ms + - light.turn_on: + id: test_rgb_light + flash_length: !lambda return 250; + - light.turn_off: + id: test_rgb_light + transition_length: 300ms # Test static effect name resolution at codegen time - light.turn_on: id: test_monochromatic_light @@ -136,6 +163,17 @@ esphome: green: 100% blue: 0% transition_length: 1s + - light.addressable_set: + id: test_addressable_transition + range_from: 0 + range_to: 4 + color: darkred + - light.addressable_set: + id: test_addressable_transition + color: tomato + - light.addressable_set: + id: test_addressable_transition + color: 0x223344 # Exercise light actions inside a trigger with non-empty Ts (number on_value # passes float). @@ -152,20 +190,77 @@ number: id: test_monochromatic_light brightness: !lambda "return x / 100.0;" +output: + - platform: template + id: light_test_restore_state_keep + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_override + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_none + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_initial + type: binary + write_action: + - lambda: "" light: - platform: binary id: test_binary_light name: Binary Light output: light_test_binary + restore_mode: RESTORE_AND_ON + resume_effect: true effects: - strobe: on_state: - logger.log: Binary light state changed + # Test restore_state: all (shorthand for {} -- restore everything, no overrides) + - platform: binary + id: test_restore_state_keep + name: Restore State Keep + output: light_test_restore_state_keep + restore_state: all + # Test restore_state: with explicit overrides + - platform: binary + id: test_restore_state_override + name: Restore State Override + output: light_test_restore_state_override + restore_state: + state: INVERT + brightness: 50% + # Test restore_state: none (explicit shorthand for "no restoring at all") + - platform: binary + id: test_restore_state_none + name: Restore State None + output: light_test_restore_state_none + restore_state: none + initial_state: + state: true + # Test restore_state: with INITIAL fields (copy initial_state:'s value on restore) + - platform: binary + id: test_restore_state_initial + name: Restore State Initial + output: light_test_restore_state_initial + initial_state: + state: true + restore_state: + state: INITIAL + brightness: INITIAL - platform: monochromatic + flash_transition_length: 100ms id: test_monochromatic_light name: Monochromatic Light output: test_ledc_1 gamma_correct: 2.8 + transition_state_publish_interval: 200ms default_transition_length: 2s effects: - strobe: @@ -260,6 +355,40 @@ light: red: 100% green: 50% blue: 50% + # Test initial_state: with a color name (a dark color also sets color_brightness) + - platform: rgb + id: test_rgb_light_initial_state_color + name: RGB Light Initial State Color + red: test_ledc_1 + green: test_ledc_2 + blue: test_ledc_3 + initial_state: + color: darkred + # Test initial_state: with every templatable field set via !lambda (color_mode is + # the only field that isn't templatable, so it's the only one left as a literal) + - platform: rgbww + id: test_rgbww_light_initial_state_lambda + name: RGBWW Light Initial State Lambda + red: test_ledc_1 + green: test_ledc_2 + blue: test_ledc_3 + cold_white: test_ledc_4 + warm_white: test_ledc_5 + cold_white_color_temperature: 153 mireds + warm_white_color_temperature: 500 mireds + color_interlock: true + initial_state: + state: !lambda "return true;" + color_mode: rgb_cold_warm_white + brightness: !lambda "return 0.8;" + color_brightness: !lambda "return 0.9;" + red: !lambda "return 1.0;" + green: !lambda "return 0.5;" + blue: !lambda "return 0.25;" + white: !lambda "return 0.3;" + color_temperature: !lambda "return 300.0;" + cold_white: !lambda "return 0.6;" + warm_white: !lambda "return 0.4;" # Test StrobeLightEffect with multiple colors - platform: monochromatic id: test_strobe_multiple diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b43187..14300244a73 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc43..7f61b1ebd5a 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 16 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 08f5f398103..76224f9c06e 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 08f5f398103..76224f9c06e 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad7744..f6a5ce62950 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 5 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test_light_json_schema.cpp b/tests/components/light/test_light_json_schema.cpp new file mode 100644 index 00000000000..d7eb9ca56f5 --- /dev/null +++ b/tests/components/light/test_light_json_schema.cpp @@ -0,0 +1,74 @@ +#include + +#include "esphome/components/json/json_util.h" +#include "esphome/components/light/light_call.h" +#include "esphome/components/light/light_json_schema.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// An `rgbw` light with `color_interlock: true` +class InterlockedRgbwOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::RGB, ColorMode::WHITE}); + return traits; + } + void write_state(LightState *state) override {} +}; + +class TestableLightCall : public LightCall { + public: + using LightCall::LightCall; + using LightCall::validate_; +}; + +LightColorValues parse(LightState &state, const char *payload) { + TestableLightCall call(&state); + json::parse_json(payload, [&](JsonObject root) { + LightJSONSchema::parse_json(state, call, root); + return true; + }); + return call.validate_(); +} + +} // namespace + +// HA's MQTT JSON schema selects the white color mode with a top-level `white` key +TEST(LightJSONSchema, TopLevelWhiteSelectsWhiteMode) { + InterlockedRgbwOutput output; + LightState state(&output); + state.remote_values.set_color_mode(ColorMode::RGB); + + auto values = parse(state, R"({"state":"ON","white":128})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_brightness(), 128.0f / 255.0f); + EXPECT_FLOAT_EQ(values.get_white(), 1.0f); +} + +TEST(LightJSONSchema, TopLevelWhiteOverridesBrightness) { + InterlockedRgbwOutput output; + LightState state(&output); + + auto values = parse(state, R"({"state":"ON","brightness":255,"white":128})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_brightness(), 128.0f / 255.0f); +} + +TEST(LightJSONSchema, ColorWStillSetsWhite) { + InterlockedRgbwOutput output; + LightState state(&output); + + auto values = parse(state, R"({"state":"ON","color":{"w":255}})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_white(), 1.0f); +} + +} // namespace esphome::light::testing diff --git a/tests/components/light/test_light_state.cpp b/tests/components/light/test_light_state.cpp new file mode 100644 index 00000000000..85bfbd09ef5 --- /dev/null +++ b/tests/components/light/test_light_state.cpp @@ -0,0 +1,68 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +class BrightnessOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override {} +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t. +class TestableLightState : public LightState { + public: + using LightState::LightState; + using LightState::start_effect_; +}; + +} // namespace + +// add_effects() is public, so an external component can exceed the codegen cap on effect count; +// an index the uint16_t can't hold must be ignored rather than wrap onto another effect. +TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) { + BrightnessOutput output; + TestableLightState state(&output); + NoopEffect effect("Noop"); + state.add_effects({&effect}); + + state.start_effect_(1); + ASSERT_EQ(state.get_current_effect_index(), 1u); + + state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect + EXPECT_EQ(state.get_current_effect_index(), 1u); +} + +// get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals. +TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) { + static constexpr GammaTable TABLE{{}, 280}; + BrightnessOutput output; + LightState state(&output); + state.set_gamma_table(&TABLE); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 2.8f); + EXPECT_EQ(state.get_gamma_table(), TABLE.lut); +} + +TEST(LightStateGamma, IsZeroWithoutATable) { + BrightnessOutput output; + LightState state(&output); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 0.0f); + EXPECT_EQ(state.get_gamma_table(), nullptr); +} + +} // namespace esphome::light::testing diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 08001855cb1..397130dee5d 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -3,7 +3,18 @@ esphome: then: - lock.lock: test_lock1 - lock.unlock: test_lock1 - - lock.open: test_lock1 + - lock.open: + id: test_lock1 + - if: + condition: + lock.is_locked: test_lock1 + then: + - logger.log: locked + - if: + condition: + lock.is_unlocked: test_lock1 + then: + - logger.log: unlocked output: - platform: gpio diff --git a/tests/components/logger/test.nrf52-mcumgr.yaml b/tests/components/logger/test.nrf52-mcumgr.yaml index 70b485daac2..e37a18a519d 100644 --- a/tests/components/logger/test.nrf52-mcumgr.yaml +++ b/tests/components/logger/test.nrf52-mcumgr.yaml @@ -5,3 +5,4 @@ esphome: logger: level: DEBUG + hardware_uart: UART0 diff --git a/tests/components/lvgl/__init__.py b/tests/components/lvgl/__init__.py new file mode 100644 index 00000000000..4c8e202cf91 --- /dev/null +++ b/tests/components/lvgl/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components.lvgl import LVGL_VERSION +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real to_code needs a display and widgets. The unit tests only need the LVGL + # headers and the animation code. + async def to_code_testing(config: ConfigType) -> None: + cg.add_library("lvgl/lvgl", LVGL_VERSION) + cg.add_build_flag("-DLV_CONF_SKIP=1") + cg.add_define("USE_LVGL_ANIMATION") + + manifest.to_code = to_code_testing diff --git a/tests/components/lvgl/animation_test.cpp b/tests/components/lvgl/animation_test.cpp new file mode 100644 index 00000000000..0ccb412f043 --- /dev/null +++ b/tests/components/lvgl/animation_test.cpp @@ -0,0 +1,184 @@ +#include +#include +#include +#include +#include +#include "esphome/components/lvgl/animation.h" + +namespace esphome::lvgl::testing { + +namespace { + +// The update callback is a plain function pointer, so it cannot capture; results go to a global. +std::vector updates; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +void record_update(const lv_coord_t *data) { updates.push_back(data[0]); } + +// Exposes the running state so tests can wait for the animation to finish. +class TestAnimation : public LvAnimation<1> { + public: + TestAnimation(lv_coord_t from, lv_coord_t to, uint32_t duration_ms, uint32_t start_delay_ms = 0) + : LvAnimation<1>(record_update, {TemplatableValue(from)}, {TemplatableValue(to)}) { + this->set_duration(duration_ms); + this->set_start_delay(start_delay_ms); + this->add_on_start_callback([this]() { this->start_count++; }); + this->add_on_stop_callback([this]() { this->stop_count++; }); + } + + bool is_running() const { return this->state_ != AnimationState::STOPPED; } + + // Returns true if the animation stopped before the timeout. + bool run_until_stopped(uint32_t timeout_ms = 1000) { + const uint32_t begin = millis(); + while (this->is_running() && millis() - begin < timeout_ms) { + this->loop(); + delay(1); + } + return !this->is_running(); + } + + void run_for(uint32_t duration_ms) { + const uint32_t begin = millis(); + while (millis() - begin < duration_ms) { + this->loop(); + delay(1); + } + } + + int start_count{0}; + int stop_count{0}; +}; + +lv_coord_t max_update() { + lv_coord_t result = updates.front(); + for (auto value : updates) + result = std::max(result, value); + return result; +} + +} // namespace + +class LvAnimationTest : public ::testing::Test { + protected: + void SetUp() override { updates.clear(); } +}; + +TEST_F(LvAnimationTest, LinearRunsToEndAndStopsOnce) { + TestAnimation anim(0, 100, 20); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.front(), 0); + EXPECT_EQ(updates.back(), 100); + EXPECT_EQ(anim.start_count, 1); + EXPECT_EQ(anim.stop_count, 1); + + // Nothing more happens once stopped. + const size_t update_count = updates.size(); + anim.run_for(10); + EXPECT_EQ(updates.size(), update_count); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, ZeroDurationDoesNotStart) { + TestAnimation anim(0, 100, 0); + anim.start(); + EXPECT_FALSE(anim.is_running()); + EXPECT_EQ(anim.start_count, 0); + EXPECT_TRUE(updates.empty()); +} + +TEST_F(LvAnimationTest, StartDelayHoldsBackUpdates) { + TestAnimation anim(0, 100, 20, 30); + anim.start(); + EXPECT_TRUE(anim.is_running()); + EXPECT_TRUE(updates.empty()); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 100); +} + +// Round trip maps the end of the duration back to the start value, so completion must not depend on the +// mapped value reaching 1.0. +TEST_F(LvAnimationTest, RoundTripStopsAtStartValue) { + LvAnimationTimingRoundTrip timing(0.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 0); + EXPECT_EQ(anim.stop_count, 1); +} + +// The pause maps to 1.0 in the middle of the duration; the animation must still play the return leg. +TEST_F(LvAnimationTest, RoundTripWithPausePlaysReturnLeg) { + LvAnimationTimingRoundTrip timing(0.5f); + TestAnimation anim(0, 100, 40); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(max_update(), 100); + EXPECT_EQ(updates.back(), 0); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, GravityStops) { + LvAnimationTimingGravity timing(0.5f, 0.5f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, EaseInOutEndsAtTarget) { + LvAnimationTimingEaseInOut timing(1.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 100); +} + +TEST_F(LvAnimationTest, LoopRestartsOnlyAfterReachingEnd) { + TestAnimation anim(0, 100, 20); + anim.set_loop(true); + anim.start(); + anim.run_for(100); + anim.stop(); + + EXPECT_GE(anim.start_count, 2); + // Each restart is a drop in value, and must follow a completed cycle that reached the end value. + for (size_t i = 1; i < updates.size(); i++) { + if (updates[i] < updates[i - 1]) + EXPECT_EQ(updates[i - 1], 100) << "update " << i << " restarted before the end was reached"; + } +} + +// The first frame of the next cycle must not be drawn in the same pass as the final frame of the last one. +TEST_F(LvAnimationTest, LoopRestartKeepsFinalFrame) { + TestAnimation anim(0, 100, 20); + anim.set_loop(true); + anim.start(); + const uint32_t begin = millis(); + while (millis() - begin < 100) { + const size_t before = updates.size(); + anim.loop(); + ASSERT_LE(updates.size() - before, 1u); + delay(1); + } + anim.stop(); + EXPECT_GE(anim.start_count, 2); +} + +TEST_F(LvAnimationTest, LoopWithRoundTripKeepsCycling) { + LvAnimationTimingRoundTrip timing(0.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.set_loop(true); + anim.start(); + anim.run_for(100); + EXPECT_GE(anim.start_count, 2); + EXPECT_GE(anim.stop_count, 1); + anim.stop(); +} + +} // namespace esphome::lvgl::testing diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 07c492db356..18a7fd0913a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,6 +30,25 @@ binary_sensor: widget: button_button state: pressed +globals: + - id: counter + type: int + +script: + - id: add_row + then: + - lvgl.list.add: + id: test_list_id + label: + text: row + +esphome: + on_boot: + then: + - lvgl.theme.update: + obj: + border_width: 2 + lvgl: id: lvgl_id rotation: 90 @@ -548,6 +567,8 @@ lvgl: #endif return std::string(buf); align: top_left + text_letter_space: -4 + text_line_space: -4 - container: align: center anim_duration: 1s @@ -1291,7 +1312,7 @@ lvgl: then: - logger.log: format: "table selected row %u col %u" - args: [row, column] + args: [(unsigned)row, (unsigned)column] on_click: then: - lvgl.table.cell.update: @@ -1337,6 +1358,17 @@ lvgl: id: checkbox_id text: Checkbox align: bottom_right + - list: + id: list_a + on_add: + - lvgl.list.add: + id: list_b # fires list_b's on_add + label: {text: x} + - list: + id: list_b + on_add: + - logger.log: added + - list: id: test_list_id align: top_right @@ -1347,10 +1379,12 @@ lvgl: - logger.log: format: "list entry added at %d" args: [list_index] + - lambda: "id(counter)++;" on_remove: - logger.log: format: "list entry removed at %d" args: [list_index] + - lambda: "id(counter)--;" on_click: - lvgl.list.add_text: id: test_list_id diff --git a/tests/components/lvgl/test-debug-outline.esp32-idf.yaml b/tests/components/lvgl/test-debug-outline.esp32-idf.yaml new file mode 100644 index 00000000000..73418962ffd --- /dev/null +++ b/tests/components/lvgl/test-debug-outline.esp32-idf.yaml @@ -0,0 +1,34 @@ +# Kept separate from the main lvgl-package.yaml so that enabling debug_outline +# here doesn't add its extra style calls to the memory-impact baseline that +# every other lvgl test change is measured against. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: debug_outline_display + dimensions: + width: 240 + height: 320 + data_rate: 80MHz + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + update_interval: never + +lvgl: + debug_outline: true + displays: + - debug_outline_display + widgets: + - obj: + id: debug_outline_root + widgets: + - label: + text: Title + - button: + widgets: + - label: + text: OK diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 3fa54fa3d62..22d051da8eb 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -40,6 +40,10 @@ lvgl: - round_trip - type: ease_in_out weight: 0.5 + - type: ease_in + weight: 0.3 + - type: ease_out + weight: 0.7 on_start: - logger.log: anim started on_stop: diff --git a/tests/components/max6956/common-actions.yaml b/tests/components/max6956/common-actions.yaml new file mode 100644 index 00000000000..04886342c67 --- /dev/null +++ b/tests/components/max6956/common-actions.yaml @@ -0,0 +1,14 @@ +button: + - platform: template + name: MAX6956 Actions + on_press: + - max6956.set_brightness_global: 10 + - max6956.set_brightness_global: + id: max6956_1 + brightness_global: !lambda return 5; + - max6956.set_brightness_mode: + id: max6956_1 + brightness_mode: segment + - max6956.set_brightness_mode: + id: max6956_1 + brightness_mode: !lambda return max6956::GLOBAL; diff --git a/tests/components/max6956/test-actions.esp8266-ard.yaml b/tests/components/max6956/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..96c170a9f0e --- /dev/null +++ b/tests/components/max6956/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max6956: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/mcp4461/common-actions.yaml b/tests/components/mcp4461/common-actions.yaml new file mode 100644 index 00000000000..09dbccd554d --- /dev/null +++ b/tests/components/mcp4461/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: MCP4461 Actions + on_press: + - mcp4461.wiper.increase: + id: digipot_wiper_2 + - mcp4461.wiper.set_terminal: + id: digipot_wiper_2 + terminal: w + enable: true diff --git a/tests/components/mcp4461/test-actions.esp8266-ard.yaml b/tests/components/mcp4461/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..d489a2feac9 --- /dev/null +++ b/tests/components/mcp4461/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mcp4461: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/mdns/test-signed-ota.esp32-s3-idf.yaml b/tests/components/mdns/test-signed-ota.esp32-s3-idf.yaml new file mode 100644 index 00000000000..1948ccc3291 --- /dev/null +++ b/tests/components/mdns/test-signed-ota.esp32-s3-idf.yaml @@ -0,0 +1,15 @@ +# Signed OTA plus the native api: the only combination that emits ota_signed. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index 88d04d0ff06..dcd04e017ad 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -1,68 +1,87 @@ -i2s_audio: - i2s_lrclk_pin: 13 - i2s_bclk_pin: 14 - i2s_mclk_pin: 15 - speaker: - platform: i2s_audio id: test_speaker - i2s_dout_pin: 18 + i2s_audio_id: i2s_audio_bus + i2s_dout_pin: ${i2s_dout_pin} dac_type: external media_player: - platform: speaker + id: test_media_player name: None - speaker: test_speaker + announcement_pipeline: + speaker: test_speaker + format: NONE on_state: - - media_player.play: - - media_player.play_media: http://localhost/media.mp3 - - media_player.play_media: !lambda 'return "http://localhost/media.mp3";' + - media_player.play: test_media_player + - media_player.play_media: + id: test_media_player + media_url: http://localhost/media.mp3 + - media_player.play_media: + id: test_media_player + media_url: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; on_idle: - - media_player.pause: + - media_player.pause: test_media_player on_play: + - media_player.stop: test_media_player - media_player.stop: - - media_player.stop: + id: test_media_player announcement: true - on_announcement: - - media_player.play: - on_turn_on: - - media_player.play: - on_turn_off: - media_player.stop: + id: test_media_player + announcement: !lambda 'return id(test_media_player).is_muted();' + on_announcement: + - media_player.play: test_media_player + on_turn_on: + - media_player.play: test_media_player + on_turn_off: + - media_player.stop: test_media_player on_pause: - - media_player.toggle: - - media_player.turn_on: - - media_player.turn_off: - - media_player.next: - - media_player.previous: - - media_player.mute: - - media_player.unmute: - - media_player.repeat_off: - - media_player.repeat_one: - - media_player.repeat_all: - - media_player.shuffle: - - media_player.unshuffle: - - media_player.group_join: - - media_player.clear_playlist: + - media_player.toggle: test_media_player + - media_player.turn_on: test_media_player + - media_player.turn_off: test_media_player + - media_player.next: test_media_player + - media_player.previous: test_media_player + - media_player.mute: test_media_player + - media_player.unmute: test_media_player + - media_player.repeat_off: test_media_player + - media_player.repeat_one: test_media_player + - media_player.repeat_all: test_media_player + - media_player.shuffle: test_media_player + - media_player.unshuffle: test_media_player + - media_player.group_join: test_media_player + - media_player.clear_playlist: test_media_player - wait_until: - media_player.is_idle: + media_player.is_idle: test_media_player - wait_until: - media_player.is_playing: + media_player.is_playing: test_media_player - wait_until: - media_player.is_announcing: + media_player.is_announcing: test_media_player - wait_until: - media_player.is_paused: + media_player.is_paused: test_media_player - wait_until: - media_player.is_on: + media_player.is_on: test_media_player - wait_until: - media_player.is_off: + media_player.is_off: test_media_player - wait_until: - media_player.is_muted: - - media_player.volume_up: - - media_player.volume_down: - - media_player.volume_set: 50% - - media_player.enqueue: http://localhost/media.mp3 - - media_player.enqueue: !lambda 'return "http://localhost/media.mp3";' + media_player.is_muted: test_media_player + - media_player.volume_up: test_media_player + - media_player.volume_down: test_media_player + - media_player.volume_set: + id: test_media_player + volume: 50% + - media_player.volume_set: + id: test_media_player + volume: !lambda 'return id(test_media_player).volume / 2;' - media_player.enqueue: + id: test_media_player + media_url: http://localhost/media.mp3 + - media_player.enqueue: + id: test_media_player + media_url: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + - media_player.enqueue: + id: test_media_player media_url: http://localhost/media.mp3 announcement: true diff --git a/tests/components/media_player/test.esp32-idf.yaml b/tests/components/media_player/test.esp32-idf.yaml new file mode 100644 index 00000000000..3e55bfb742f --- /dev/null +++ b/tests/components/media_player/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_dout_pin: GPIO18 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + media_player: !include common.yaml + +wifi: + ap: diff --git a/tests/components/mhz19/common-actions.yaml b/tests/components/mhz19/common-actions.yaml new file mode 100644 index 00000000000..2213019da72 --- /dev/null +++ b/tests/components/mhz19/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: MH-Z19 Actions + on_press: + - mhz19.calibrate_zero: mhz19_sensor + - mhz19.abc_enable: mhz19_sensor + - mhz19.abc_disable: mhz19_sensor + - mhz19.detection_range_set: + id: mhz19_sensor + detection_range: 2000ppm diff --git a/tests/components/mhz19/common.yaml b/tests/components/mhz19/common.yaml index b12ca501975..7f5e13b5650 100644 --- a/tests/components/mhz19/common.yaml +++ b/tests/components/mhz19/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: mhz19 + id: mhz19_sensor co2: name: MH-Z19 CO2 Value temperature: diff --git a/tests/components/mhz19/test-actions.esp8266-ard.yaml b/tests/components/mhz19/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..b3e895ffc86 --- /dev/null +++ b/tests/components/mhz19/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + mhz19: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/microphone/common-actions.yaml b/tests/components/microphone/common-actions.yaml new file mode 100644 index 00000000000..885282bcdc7 --- /dev/null +++ b/tests/components/microphone/common-actions.yaml @@ -0,0 +1,12 @@ +# capture, stop_capture and is_capturing on the external microphone. +button: + - platform: template + name: Microphone Capture + on_press: + - if: + condition: + microphone.is_capturing: mic_id_external + then: + - microphone.stop_capture: mic_id_external + else: + - microphone.capture: mic_id_external diff --git a/tests/components/microphone/test-actions.esp32-idf.yaml b/tests/components/microphone/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..c2f27a42861 --- /dev/null +++ b/tests/components/microphone/test-actions.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_din_pin1: GPIO33 + i2s_din_pin2: GPIO34 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + microphone: !include common-pdm.yaml + actions: !include common-actions.yaml diff --git a/tests/components/midea/common-actions.yaml b/tests/components/midea/common-actions.yaml new file mode 100644 index 00000000000..adda7ce3d9b --- /dev/null +++ b/tests/components/midea/common-actions.yaml @@ -0,0 +1,26 @@ +button: + - platform: template + name: Midea Actions + on_press: + - midea_ac.follow_me: + id: midea_unit + temperature: 23.0 + beeper: true + - midea_ac.follow_me: + id: midea_unit + temperature: !lambda return id(midea_unit).target_temperature + 1.0f; + use_fahrenheit: !lambda return false; + - midea_ac.display_toggle: + id: midea_unit + - midea_ac.swing_step: + id: midea_unit + - midea_ac.beeper_on: + id: midea_unit + - midea_ac.beeper_off: + id: midea_unit + - midea_ac.power_on: + id: midea_unit + - midea_ac.power_off: + id: midea_unit + - midea_ac.power_toggle: + id: midea_unit diff --git a/tests/components/midea/test-actions.esp8266-ard.yaml b/tests/components/midea/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..dcb7f75b9ed --- /dev/null +++ b/tests/components/midea/test-actions.esp8266-ard.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea: !include common.yaml + actions: !include common-actions.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/midea/test.esp32-idf.yaml b/tests/components/midea/test.esp32-idf.yaml index 5ad22b5b93c..edaa35691be 100644 --- a/tests/components/midea/test.esp32-idf.yaml +++ b/tests/components/midea/test.esp32-idf.yaml @@ -12,4 +12,4 @@ wifi: interval: - interval: 10s then: - - lambda: ESP_LOGD("test", "%u", millis()); + - lambda: ESP_LOGD("test", "%" PRIu32, millis()); diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index a867b726edd..6bdee9e54a6 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -1,3 +1,10 @@ +esphome: + on_boot: + then: + - display.page.show_next: page_display + - display.page.show_previous: + id: page_display + display: - platform: mipi_spi spi_16: true @@ -32,7 +39,11 @@ display: bus_mode: single - platform: mipi_spi + id: page_display model: WAVESHARE-1.83-V2 + pages: + - lambda: it.rectangle(0, 0, 10, 10); + - lambda: it.rectangle(10, 10, 10, 10); dc_pin: allow_other_uses: true number: ${dc_pin} diff --git a/tests/components/mitsubishi/__init__.py b/tests/components/mitsubishi/__init__.py new file mode 100644 index 00000000000..8e15f6d479b --- /dev/null +++ b/tests/components/mitsubishi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/mitsubishi/mitsubishi_test.cpp b/tests/components/mitsubishi/mitsubishi_test.cpp new file mode 100644 index 00000000000..3bc84a5db92 --- /dev/null +++ b/tests/components/mitsubishi/mitsubishi_test.cpp @@ -0,0 +1,65 @@ +#include +#include "esphome/components/mitsubishi/mitsubishi.h" + +namespace esphome::mitsubishi::testing { + +TEST(MitsubishiClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) { + MitsubishiClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(MitsubishiClimateTest, HeatCoolOverrideHiddenWithHeatAndCool) { + MitsubishiClimate climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.get_traits(); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); +} + +TEST(MitsubishiClimateTest, FanModesFor3Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_3L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_AUTO)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_LOW)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MEDIUM)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_HIGH)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesFor4Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_4L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesForQuietAnd4Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_Q4L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesFollowTheLastSetFanMode) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_Q4L); + climate.set_fan_mode(MITSUBISHI_FAN_3L); + auto traits = climate.get_traits(); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_HIGH)); +} + +} // namespace esphome::mitsubishi::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 6029327e9ab..b643500234d 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" -#include "esphome/components/mitsubishi_cn105/automation.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 3f7e8c8f954..eb5b55aa068 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -37,4 +37,4 @@ esphome: - mitsubishi_cn105.vane.control: id: ac vertical: - direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; + direction: !lambda return VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp index 928d4123f61..17cb1cb8c73 100644 --- a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -63,12 +63,4 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); } -TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { - MitsubishiCN105Component hub; - VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); - - action.play(); - - EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); -} } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mixer/common-actions.yaml b/tests/components/mixer/common-actions.yaml new file mode 100644 index 00000000000..f86009e6284 --- /dev/null +++ b/tests/components/mixer/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: Duck media + on_press: + - mixer_speaker.apply_ducking: + id: source_speaker_1_id + decibel_reduction: !lambda return 20; + duration: !lambda return 250; diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index 55e96df4c27..489475c794f 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -3,7 +3,7 @@ esphome: then: - mixer_speaker.apply_ducking: id: source_speaker_1_id - decibel_reduction: 10 + decibel_reduction: 255 duration: 1s speaker: diff --git a/tests/components/mixer/test-actions.esp32-idf.yaml b/tests/components/mixer/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..7c214bf8641 --- /dev/null +++ b/tests/components/mixer/test-actions.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dout_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + mixer: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/mk2pvrouter/__init__.py b/tests/components/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..a750a4217c9 --- /dev/null +++ b/tests/components/mk2pvrouter/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Listener storage is sized by code generation; the gtests register one listener per hub. + cg.add_define("MK2PVROUTER_LISTENER_COUNT", 1) + + manifest.to_code = to_code_testing diff --git a/tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp b/tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp new file mode 100644 index 00000000000..4303bc8a3e6 --- /dev/null +++ b/tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp @@ -0,0 +1,21 @@ +#include + +#include "esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterBinarySensorTest, ZeroPublishesOff) { + Mk2PVRouterBinarySensor sensor("R1"); + sensor.publish_val("0"); + ASSERT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +TEST(Mk2PVRouterBinarySensorTest, OnePublishesOn) { + Mk2PVRouterBinarySensor sensor("R1"); + sensor.publish_val("1"); + ASSERT_TRUE(sensor.has_state()); + EXPECT_TRUE(sensor.state); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/common.h b/tests/components/mk2pvrouter/common.h new file mode 100644 index 00000000000..c954c8bb698 --- /dev/null +++ b/tests/components/mk2pvrouter/common.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" + +namespace esphome::mk2pvrouter::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +class TestableMk2PVRouter : public Mk2PVRouter { + public: + using Mk2PVRouter::Mk2PVRouter; + using Mk2PVRouter::State; + using Mk2PVRouter::buf_index_; + using Mk2PVRouter::calculate_crc_; + using Mk2PVRouter::check_crc_; + using Mk2PVRouter::process_group_; + using Mk2PVRouter::state_; +}; + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml index 4421c098545..c3efb85196b 100644 --- a/tests/components/mk2pvrouter/common.yaml +++ b/tests/components/mk2pvrouter/common.yaml @@ -3,44 +3,78 @@ mk2pvrouter: uart_id: uart_bus sensor: + # Exact tags - platform: mk2pvrouter name: Power tag: P mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: W - device_class: power - state_class: measurement - accuracy_decimals: 0 - - - platform: mk2pvrouter - name: Voltage - tag: V - mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: V - device_class: voltage - state_class: measurement - accuracy_decimals: 2 - filters: - # Device sends voltage * 100 - - multiply: 0.01 + # An explicit value must win over the tag default + device_class: "" - platform: mk2pvrouter name: Energy tag: E mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - accuracy_decimals: 0 + + # Indexed tags + - platform: mk2pvrouter + name: Power Phase 1 + tag: P1 + mk2pvrouter_id: test_mk2pvrouter - platform: mk2pvrouter - name: Temperature + name: Power Phase 2 + tag: P2 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Voltage Phase 1 + tag: V1 + mk2pvrouter_id: test_mk2pvrouter + + # The x0.01 correction is applied before filters, so a user filter stacks on the corrected value + - platform: mk2pvrouter + name: Voltage Phase 2 Scaled + tag: V2 + mk2pvrouter_id: test_mk2pvrouter + filters: + - multiply: 1.0 + + - platform: mk2pvrouter + name: Temperature 1 tag: T1 mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: "°C" - device_class: temperature - state_class: measurement - accuracy_decimals: 2 - filters: - # Device sends temperature * 100 - - multiply: 0.01 + + - platform: mk2pvrouter + name: Diversion Rate 1 + tag: D1 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Relay 1 State + tag: R1 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Relay 10 State + tag: R10 + mk2pvrouter_id: test_mk2pvrouter + +binary_sensor: + # Test relay as binary sensor + - platform: mk2pvrouter + name: Relay 2 Binary + tag: R2 + mk2pvrouter_id: test_mk2pvrouter + +text_sensor: + - platform: mk2pvrouter + name: S per MC + tag: S_MC + mk2pvrouter_id: test_mk2pvrouter + + # Test another text tag + - platform: mk2pvrouter + name: Status + tag: STATUS + mk2pvrouter_id: test_mk2pvrouter diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp new file mode 100644 index 00000000000..84106eb0b77 --- /dev/null +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -0,0 +1,112 @@ +#include + +#include + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "common.h" + +namespace esphome::mk2pvrouter::testing { + +namespace { +class TestListener final : public Mk2PVRouterListener { + public: + explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} + void publish_val(const char *val) override { + this->published_ = true; + this->last_val_ = val; + } + + bool published_{false}; + std::string last_val_; +}; + +class Mk2PVRouterTest : public ::testing::Test { + protected: + void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } + + // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. + template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + + TestableMk2PVRouter sut_; + TestListener listener_{"P1"}; +}; +} // namespace + +TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { + TestListener listener("P1"); + EXPECT_STREQ(listener.get_tag(), "P1"); +} + +TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { + // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. + const char grp[] = "P1\t1234\t="; + EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); +} + +TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { + const char grp[] = "P1\t1234\t="; + EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); +} + +TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { + const char grp[] = "P1\t1234\t!"; + EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); +} + +TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { + const char grp[] = ""; + EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { + this->process_("P1\t1234\t="); + EXPECT_TRUE(this->listener_.published_); + EXPECT_EQ(this->listener_.last_val_, "1234"); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupDropsGroupWithBadCrc) { + this->process_("P1\t1234\t!"); + EXPECT_FALSE(this->listener_.published_); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupDropsGroupMissingValue) { + // "P1\t" sums to 0x2A ('*'), so the CRC passes, but there is no second TAB for the value. + this->process_("P1\t*"); + EXPECT_FALSE(this->listener_.published_); +} + +TEST_F(Mk2PVRouterTest, LoopParsesFullFrameAndPublishesToListener) { + MockUARTComponent uart; + this->sut_.set_uart_parent(&uart); + + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::WAITING_FOR_START); + + // STX, LF "P1\t1234\t=" CR, ETX + uart.push_rx({0x02, 0x0a, 'P', '1', '\t', '1', '2', '3', '4', '\t', '=', 0x0d, 0x03}); + + this->sut_.loop(); // Consume bytes up to and including START_FRAME. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::START_FRAME_RECEIVED); + + this->sut_.loop(); // Buffer the frame body up to END_FRAME. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::END_FRAME_RECEIVED); + + this->sut_.loop(); // Parse the buffered groups and publish them. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::WAITING_FOR_START); + EXPECT_EQ(this->sut_.buf_index_, 0); + EXPECT_TRUE(this->listener_.published_); + EXPECT_EQ(this->listener_.last_val_, "1234"); +} + +TEST_F(Mk2PVRouterTest, LoopIgnoresBytesBeforeStartFrame) { + MockUARTComponent uart; + this->sut_.set_uart_parent(&uart); + + uart.push_rx({'g', 'a', 'r', 'b', 'a', 'g', 'e', 0x02}); + + this->sut_.loop(); + + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::START_FRAME_RECEIVED); + EXPECT_EQ(this->sut_.buf_index_, 0); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp b/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp new file mode 100644 index 00000000000..cf70a32a650 --- /dev/null +++ b/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp @@ -0,0 +1,25 @@ +#include + +#include "esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterSensorTest, ScaleCentiTrueScalesByOneHundredth) { + Mk2PVRouterSensor sensor("V", true); + sensor.publish_val("23042"); + EXPECT_FLOAT_EQ(sensor.get_raw_state(), 230.42f); +} + +TEST(Mk2PVRouterSensorTest, ScaleCentiFalseDoesNotScale) { + Mk2PVRouterSensor sensor("P1", false); + sensor.publish_val("1234"); + EXPECT_FLOAT_EQ(sensor.get_raw_state(), 1234.0f); +} + +TEST(Mk2PVRouterSensorTest, UnparseableValueIsNotPublished) { + Mk2PVRouterSensor sensor("V1", true); + sensor.publish_val("not-a-number"); + EXPECT_FALSE(sensor.has_state()); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp b/tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp new file mode 100644 index 00000000000..666797906b9 --- /dev/null +++ b/tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp @@ -0,0 +1,21 @@ +#include + +#include "esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterTextSensorTest, PublishesValueVerbatim) { + Mk2PVRouterTextSensor sensor("S_MC"); + sensor.publish_val("-1234"); + ASSERT_TRUE(sensor.has_state()); + EXPECT_EQ(sensor.get_state(), "-1234"); +} + +TEST(Mk2PVRouterTextSensorTest, ShorterValueReplacesPreviousOne) { + Mk2PVRouterTextSensor sensor("S_MC"); + sensor.publish_val("-1234"); + sensor.publish_val("5"); + EXPECT_EQ(sensor.get_state(), "5"); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5b..3bdfa094e0e 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5cf..ce2965e449d 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..d6a29d7175b --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb73..b488e51f3c8 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..49e89eaa20f --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index a1d27cdbd52..5ed997bfedb 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -101,6 +101,13 @@ button: payload: Hello qos: 2 retain: true + - mqtt.publish: + topic: !lambda return "some/topic/button/lambda"; + payload: !lambda |- + std::string payload = "Hello"; + return payload; + qos: !lambda return 1; + retain: !lambda return false; climate: - platform: thermostat diff --git a/tests/components/network/test-priority.esp32-idf.yaml b/tests/components/network/test-priority.esp32-idf.yaml index baa821a2348..ccafd74543f 100644 --- a/tests/components/network/test-priority.esp32-idf.yaml +++ b/tests/components/network/test-priority.esp32-idf.yaml @@ -3,6 +3,11 @@ # it must actually compile (not just validate) to guard the reconciler wiring. # WiFi is listed first so the build also exercises the wifi-primary branch in # network/util.cpp (the ethernet-primary branch matches the legacy order). +# PSRAM guaranteed plus high performance networking: a dual build keeps wifi's +# PSRAM tier, so this compiles the sdkconfig a wifi + ethernet audio device gets. +psram: + ignore_not_found: false + wifi: ssid: MySSID password: password1 @@ -21,3 +26,4 @@ network: priority: - wifi - ethernet + enable_high_performance: true diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d79e3ee2ed2..4f4347d89a9 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -3,6 +3,7 @@ esphome: - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' - display.nextion.set_brightness: 80% + - display.nextion.set_brightness: !lambda 'return 0.5;' # Binary sensor publish action tests - binary_sensor.nextion.publish: diff --git a/tests/components/nextion/common_waveform_lambda.yaml b/tests/components/nextion/common_waveform_lambda.yaml new file mode 100644 index 00000000000..70b1c62d87d --- /dev/null +++ b/tests/components/nextion/common_waveform_lambda.yaml @@ -0,0 +1,8 @@ +# Waveform commands from a lambda with no waveform sensor configured, so +# USE_NEXTION_WAVEFORM is not defined. +display: + - platform: nextion + id: main_lcd + lambda: |- + id(main_lcd).open_waveform_channel(37, 0, 10); + id(main_lcd).add_waveform_data(37, 0, 42); diff --git a/tests/components/nextion/test-waveform-lambda.esp32-idf.yaml b/tests/components/nextion/test-waveform-lambda.esp32-idf.yaml new file mode 100644 index 00000000000..53535ab6c46 --- /dev/null +++ b/tests/components/nextion/test-waveform-lambda.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + waveform_lambda: !include common_waveform_lambda.yaml diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index b1a16ebfedd..3199dd90f89 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -11,3 +11,30 @@ sensor: - platform: number name: "Test Number Value" source_id: number_test_number + +button: + - platform: template + name: "Test Number Actions" + on_press: + - number.set: + id: number_test_number + value: 42 + - number.set: + id: number_test_number + value: !lambda |- + float next = id(number_test_number).state + 1; + return next; + - number.increment: number_test_number + - number.decrement: + id: number_test_number + cycle: false + - number.to_min: number_test_number + - number.to_max: number_test_number + - number.operation: + id: number_test_number + operation: increment + cycle: false + - number.operation: + id: number_test_number + operation: !lambda return NUMBER_OP_DECREMENT; + cycle: !lambda return true; diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index d8d04850cfd..df8f85596f8 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -84,3 +84,14 @@ esphome: id: online_rgba_image url: !lambda |- return str_sprintf("http://homeassistant.local:8123"); + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + update: false + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + update: !lambda return id(online_rgba_image).get_width() > 0; + - online_image.release: online_rgba_image + - online_image.release: + id: online_rgb24_image diff --git a/tests/components/ota/allow_plaintext_upload.yaml b/tests/components/ota/allow_plaintext_upload.yaml new file mode 100644 index 00000000000..be222ec4332 --- /dev/null +++ b/tests/components/ota/allow_plaintext_upload.yaml @@ -0,0 +1,13 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/ota/test.nrf52-adafruit.yaml b/tests/components/ota/test.nrf52-adafruit.yaml index e8ac96f051e..d0a44d2ff7b 100644 --- a/tests/components/ota/test.nrf52-adafruit.yaml +++ b/tests/components/ota/test.nrf52-adafruit.yaml @@ -2,3 +2,7 @@ zephyr_ble_server: ota: - platform: zephyr_mcumgr + status: + pin: + number: P0.15 + inverted: true diff --git a/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml new file mode 100644 index 00000000000..834e6197acc --- /dev/null +++ b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include allow_plaintext_upload.yaml diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index df20dcde2b0..6448b3cfe6d 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -6,6 +6,9 @@ esphome: - output.set_level: id: output_light_output_1 level: 50% + - output.set_level: + id: output_light_output_1 + level: !lambda return 0.25f; - output.set_min_power: id: output_light_output_1 min_power: 20% diff --git a/tests/components/pid/__init__.py b/tests/components/pid/__init__.py new file mode 100644 index 00000000000..f8b7080743f --- /dev/null +++ b/tests/components/pid/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["climate", "output", "sensor"] diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 320e5f775fe..c78ad8fc1ef 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -12,6 +12,15 @@ esphome: kp: 0.0 ki: 0.0 kd: 0.0 + - climate.pid.set_deadband_control_parameters_multipliers: + id: pid_climate + kp_multiplier: 0.0 + ki_multiplier: 0.0 + kd_multiplier: 0.0 + - climate.pid.set_deadband_threshold_parameters: + id: pid_climate + threshold_high: 0.5°C + threshold_low: !lambda return -0.5f; - climate.pid.reset_integral_term: pid_climate output: @@ -30,6 +39,26 @@ sensor: } return 0.0; update_interval: 60s + - platform: pid + id: pid_kp_multiplier + climate_id: pid_climate + type: KP_DEADBAND_MULTIPLIER + - platform: pid + id: pid_ki_multiplier + climate_id: pid_climate + type: KI_DEADBAND_MULTIPLIER + - platform: pid + id: pid_kd_multiplier + climate_id: pid_climate + type: KD_DEADBAND_MULTIPLIER + - platform: pid + id: pid_threshold_high + climate_id: pid_climate + type: DEADBAND_THRESHOLD_HIGH + - platform: pid + id: pid_threshold_low + climate_id: pid_climate + type: DEADBAND_THRESHOLD_LOW climate: - platform: pid diff --git a/tests/components/pid/test_deadband_thresholds.cpp b/tests/components/pid/test_deadband_thresholds.cpp new file mode 100644 index 00000000000..ba37a16d8d5 --- /dev/null +++ b/tests/components/pid/test_deadband_thresholds.cpp @@ -0,0 +1,41 @@ +#include + +#include "esphome/components/pid/pid_climate.h" + +namespace esphome::pid { + +TEST(PIDClimateDeadbandThresholds, InvalidThresholdsDoNotChangeController) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_FALSE(climate.set_deadband_thresholds(2.0f, 1.0f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), -1.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 1.0f); +} + +TEST(PIDClimateDeadbandThresholds, ValidThresholdsChangeController) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_TRUE(climate.set_deadband_thresholds(-2.0f, 0.5f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), -2.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.5f); +} + +TEST(PIDClimateDeadbandThresholds, EqualThresholdsDisableDeadband) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_TRUE(climate.set_deadband_thresholds(0.0f, 0.0f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), 0.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.0f); + EXPECT_FALSE(climate.in_deadband()); +} + +} // namespace esphome::pid diff --git a/tests/components/pipsolar/common-actions.yaml b/tests/components/pipsolar/common-actions.yaml new file mode 100644 index 00000000000..960c512a1dc --- /dev/null +++ b/tests/components/pipsolar/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Pipsolar Set Level + on_press: + - output.pipsolar.set_level: + id: inverter0_battery_recharge_voltage_out + value: !lambda return 49.0; diff --git a/tests/components/pipsolar/test-actions.esp8266-ard.yaml b/tests/components/pipsolar/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..38154131053 --- /dev/null +++ b/tests/components/pipsolar/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + pipsolar: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/pmwcs3/common-actions.yaml b/tests/components/pmwcs3/common-actions.yaml new file mode 100644 index 00000000000..c87f4c8c5b1 --- /dev/null +++ b/tests/components/pmwcs3/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: PMWCS3 Actions + on_press: + - pmwcs3.air_calibration: + - pmwcs3.water_calibration: + - pmwcs3.new_i2c_address: 0x64 + - pmwcs3.new_i2c_address: + address: !lambda return 0x65; diff --git a/tests/components/pmwcs3/test-actions.esp8266-ard.yaml b/tests/components/pmwcs3/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..33c1f8a83a3 --- /dev/null +++ b/tests/components/pmwcs3/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pmwcs3: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp new file mode 100644 index 00000000000..75f9cf6d088 --- /dev/null +++ b/tests/components/pn532/pn532_test.cpp @@ -0,0 +1,191 @@ +#include + +#include +#include +#include + +#include "esphome/components/pn532/pn532.h" + +namespace esphome::pn532 { + +namespace { + +// Stands in for the bus: acknowledges every command and answers with queued response payloads. +class FakePN532 : public PN532 { + public: + using PN532::auth_mifare_classic_block_; + using PN532::read_mifare_ultralight_bytes_; + using PN532::read_mifare_classic_block_; + using PN532::write_command_; + using PN532::write_mifare_classic_block_; + using PN532::write_mifare_ultralight_page_; + + std::deque> responses; + std::vector> written; + + protected: + bool is_read_ready() override { return true; } + bool write_data(std::span data) override { + this->written.emplace_back(data.begin(), data.end()); + return true; + } + // only used for ACK frames; index 0 is the I2C status byte + bool read_data(PN532Frame &data, size_t len) override { + data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; + return true; + } + bool read_response(uint8_t command, PN532Frame &data) override { + if (this->responses.empty()) + return false; + data.assign(this->responses.front().begin(), this->responses.front().end()); + this->responses.pop_front(); + return true; + } +}; + +std::vector bytes_of(std::span bytes) { return {bytes.begin(), bytes.end()}; } + +// Extracts the command bytes (after TFI) from a normal information frame +std::vector frame_data(const std::vector &frame) { + // preamble, start code (2), LEN, LCS, TFI, data..., DCS, postamble + return std::vector(frame.begin() + 6, frame.end() - 2); +} + +} // namespace + +TEST(PN532TagType, FromSelRes) { + EXPECT_EQ(tag_type_from_sel_res(0x08), nfc::TAG_TYPE_MIFARE_CLASSIC); // Classic 1K + EXPECT_EQ(tag_type_from_sel_res(0x18), nfc::TAG_TYPE_MIFARE_CLASSIC); // Classic 4K + EXPECT_EQ(tag_type_from_sel_res(0x09), nfc::TAG_TYPE_MIFARE_CLASSIC); // Mini + EXPECT_EQ(tag_type_from_sel_res(0x01), nfc::TAG_TYPE_MIFARE_CLASSIC); // TNP3xxx + EXPECT_EQ(tag_type_from_sel_res(0x00), nfc::TAG_TYPE_2); // Ultralight / NTAG + EXPECT_EQ(tag_type_from_sel_res(0x20), nfc::TAG_TYPE_4); // ISO-DEP (phones, DESFire) + EXPECT_EQ(tag_type_from_sel_res(0x40), nfc::TAG_TYPE_UNKNOWN); +} + +// The frame wraps the command in preamble, start code, LEN, LCS, TFI, DCS and postamble (UM0701-02, 6.2.1.1). +TEST(PN532Frame, WrapsCommand) { + FakePN532 pn532; + ASSERT_TRUE(pn532.write_command_({0x4A, 0x01, 0x00})); + ASSERT_EQ(pn532.written.size(), 1u); + EXPECT_EQ(pn532.written[0], (std::vector{0x00, 0x00, 0xFF, 0x04, 0xFC, 0xD4, 0x4A, 0x01, 0x00, 0xE1, 0x00})); +} + +// A command that cannot fit a normal information frame is refused rather than truncated. +TEST(PN532Frame, RejectsOversizedCommand) { + FakePN532 pn532; + std::array too_long{}; + EXPECT_FALSE(pn532.write_command_(too_long)); + EXPECT_TRUE(pn532.written.empty()); + EXPECT_TRUE(pn532.write_command_(std::span(too_long).first(PN532_FRAME_MAX_DATA_SIZE))); + ASSERT_EQ(pn532.written.size(), 1u); + EXPECT_EQ(pn532.written[0].size(), PN532_FRAME_MAX_DATA_SIZE + 8); +} + +// A failed write (status byte other than 0x00) must be reported as a failure. +TEST(PN532Mifare, ClassicWriteChecksStatus) { + FakePN532 pn532; + const uint8_t block[16] = {}; + pn532.responses.push_back({0x14}); // authentication error + EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block)); + pn532.responses.push_back({0x00}); + EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block)); +} + +TEST(PN532Mifare, UltralightWriteChecksStatus) { + FakePN532 pn532; + const uint8_t page[4] = {}; + pn532.responses.push_back({0x01}); // timeout + EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page)); + pn532.responses.push_back({0x00}); + EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page)); +} + +TEST(PN532Mifare, ClassicReadRejectsBadResponses) { + FakePN532 pn532; + MifareReadData data{}; + pn532.responses.emplace_back(); // empty response + EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); + pn532.responses.push_back({0x00, 0x01, 0x02}); // short block + EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); + + std::vector good(17, 0xAB); + good[0] = 0x00; + pn532.responses.push_back(good); + EXPECT_TRUE(pn532.read_mifare_classic_block_(4, data)); + EXPECT_EQ(bytes_of(data), std::vector(16, 0xAB)); +} + +// The NDEF TLV is type 0x03, a one-byte length below 255 (three bytes otherwise), the message, terminator 0xFE, +// then zero padding out to the requested length. +TEST(PN532Ndef, FillsTlv) { + FixedVector buffer; + const std::array message = {0xD1, 0x01, 0x02}; + nfc::fill_ndef_tlv(message, 8, buffer); + EXPECT_EQ(bytes_of(std::span(buffer)), + (std::vector{0x03, 0x03, 0xD1, 0x01, 0x02, 0xFE, 0x00, 0x00})); + + std::vector long_message(300, 0xAA); + nfc::fill_ndef_tlv(long_message, 320, buffer); + ASSERT_EQ(buffer.size(), 320u); + EXPECT_EQ(buffer[0], 0x03); + EXPECT_EQ(buffer[1], 0xFF); + EXPECT_EQ(buffer[2], 0x01); // 300 = 0x012C + EXPECT_EQ(buffer[3], 0x2C); + EXPECT_EQ(buffer[4], 0xAA); + EXPECT_EQ(buffer[303], 0xAA); + EXPECT_EQ(buffer[304], 0xFE); + EXPECT_EQ(buffer[319], 0x00); +} + +// Authentication carries exactly 4 UID bytes: the last 4 of a 7-byte UID. +TEST(PN532Mifare, AuthSendsFourUidBytes) { + FakePN532 pn532; + nfc::NfcTagUid uid = {0x04, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + pn532.responses.push_back({0x00}); + EXPECT_TRUE(pn532.auth_mifare_classic_block_(uid, 4, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)); + ASSERT_EQ(pn532.written.size(), 1u); + const auto cmd = frame_data(pn532.written[0]); + // InDataExchange, Tg, Cmd, Addr, key (6), UID (4) + ASSERT_EQ(cmd.size(), 14u); + EXPECT_EQ(std::vector(cmd.end() - 4, cmd.end()), (std::vector{0x33, 0x44, 0x55, 0x66})); +} + +// Reads in 16-byte chunks, keeps only the bytes asked for, and advances 4 pages per READ. +TEST(PN532Mifare, UltralightReadTrimsLastChunk) { + FakePN532 pn532; + std::vector first(17), second(17); + first[0] = second[0] = 0x00; // status + for (uint8_t i = 0; i < 16; i++) { + first[i + 1] = i; + second[i + 1] = 0x10 + i; + } + pn532.responses.push_back(first); + pn532.responses.push_back(second); + + UltralightReadBuffer data; + ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); + ASSERT_EQ(data.size(), 20u); + EXPECT_EQ(data[15], 15); + EXPECT_EQ(data[16], 0x10); + EXPECT_EQ(data[19], 0x13); + + ASSERT_EQ(pn532.written.size(), 2u); + EXPECT_EQ(frame_data(pn532.written[0]).back(), 4); // READ page 4 + EXPECT_EQ(frame_data(pn532.written[1]).back(), 8); // then page 8 +} + +TEST(PN532Mifare, UltralightReadRejectsBadResponses) { + FakePN532 pn532; + UltralightReadBuffer data; + pn532.responses.push_back({0x00, 0x01, 0x02}); // short response + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + + std::vector failed(17, 0x00); + failed[0] = 0x01; // timeout status + pn532.responses.push_back(failed); + data.clear(); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); +} + +} // namespace esphome::pn532 diff --git a/tests/components/pn532_i2c/common-actions.yaml b/tests/components/pn532_i2c/common-actions.yaml new file mode 100644 index 00000000000..2f5bd4aec42 --- /dev/null +++ b/tests/components/pn532_i2c/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: PN532 write state + on_press: + - if: + condition: + pn532.is_writing: + id: pn532_nfcc_i2c + then: + - logger.log: Writing diff --git a/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml b/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..9a413421266 --- /dev/null +++ b/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pn532_i2c: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/pn532_spi/common.yaml b/tests/components/pn532_spi/common.yaml index f9149af35fd..7af4beac6ed 100644 --- a/tests/components/pn532_spi/common.yaml +++ b/tests/components/pn532_spi/common.yaml @@ -1,6 +1,15 @@ pn532_spi: id: pn532_nfcc_spi cs_pin: ${cs_pin} + on_tag: + then: + - delay: 10ms + - lambda: |- + ESP_LOGD("test", "%s %zu %d %d", tag.get_tag_type().c_str(), tag.get_uid().size(), tag.has_ndef_message(), + nfc::has_ha_tag_ndef(tag)); + on_tag_removed: + then: + - lambda: ESP_LOGD("test", "%s", tag.get_tag_type().c_str()); binary_sensor: - platform: pn532 diff --git a/tests/components/pn7160_spi/common.yaml b/tests/components/pn7160_spi/common.yaml index 53b37b38f45..18438a2e0da 100644 --- a/tests/components/pn7160_spi/common.yaml +++ b/tests/components/pn7160_spi/common.yaml @@ -24,8 +24,19 @@ pn7160_spi: emulation_message: https://www.home-assistant.io/tag/pulse_ce tag_ttl: 1000ms on_tag: - - logger.log: "Tag" + then: + - delay: 10ms + - lambda: |- + ESP_LOGD("test", "%s %zu %d %s", tag.get_tag_type().c_str(), tag.get_uid().size(), tag.has_ndef_message(), + nfc::get_ha_tag_ndef(tag).c_str()); on_tag_removed: - - logger.log: "Tag removed" + then: + - lambda: ESP_LOGD("test", "%s", tag.get_tag_type().c_str()); on_emulated_tag_scan: - logger.log: "Tag emulated" + +binary_sensor: + - platform: nfc + nfcc_id: nfcc_pn7160_spi + name: Pulse tag + ndef_contains: pulse diff --git a/tests/components/pn71xx/pn71xx_test.cpp b/tests/components/pn71xx/pn71xx_test.cpp new file mode 100644 index 00000000000..a856400dcdd --- /dev/null +++ b/tests/components/pn71xx/pn71xx_test.cpp @@ -0,0 +1,257 @@ +#include + +#include + +#include "esphome/components/pn71xx/pn71xx.h" + +namespace esphome::pn71xx { + +namespace { + +// Stands in for the bus: records every frame written and replays queued frames on read. +class FakePN71xx : public PN71xx { + public: + using PN71xx::card_emu_t4t_get_response_; + using PN71xx::discovered_endpoint_; + using PN71xx::erase_tag_; + using PN71xx::find_or_add_tag_; + using PN71xx::transceive_; + + std::deque> to_read; + std::vector> written; + uint8_t write_failures{0}; + + protected: + uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override { return nfc::STATUS_OK; } + uint8_t process_init_response(nfc::NciMessage &rx) override { return nfc::STATUS_OK; } + std::span pmu_config() const override { return {}; } + std::span listen_mode_routing_config() const override { return {}; } + + uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) override { + if (this->to_read.empty()) + return nfc::STATUS_FAILED; + rx = nfc::NciMessage(this->to_read.front()); + this->to_read.pop_front(); + return nfc::STATUS_OK; + } + uint8_t write_nfcc(nfc::NciMessage &tx) override { + if (this->write_failures > 0) { + this->write_failures--; + return nfc::STATUS_FAILED; + } + const auto encoded = tx.encode(); + this->written.emplace_back(encoded.begin(), encoded.end()); + return nfc::STATUS_OK; + } +}; + +std::vector apdu(std::initializer_list bytes) { + std::vector msg = {nfc::NCI_PKT_MT_DATA, 0x00, static_cast(bytes.size())}; + msg.insert(msg.end(), bytes); + return msg; +} + +std::vector respond(FakePN71xx &nfcc, std::initializer_list bytes) { + CardEmuResponse response; + nfcc.card_emu_t4t_get_response_(apdu(bytes), response); + return {response.begin(), response.end()}; +} + +void select_ndef_file(FakePN71xx &nfcc) { + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}); +} + +std::vector bytes_of(const nfc::NciMessage &msg) { + return {msg.get_message().begin(), msg.get_message().end()}; +} + +const std::vector SW_OK = {0x90, 0x00}; +const std::vector SW_NOT_FOUND = {0x6A, 0x82}; + +} // namespace + +// A timed-out read must not cause the command to be sent again (NCI forbids a second command before the response). +TEST(PN71xxTransceive, ReadTimeoutDoesNotResend) { + FakePN71xx nfcc; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_NE(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +// A notification with the same GID/OID as the response (RF_DEACTIVATE_NTF) is not mistaken for it. +TEST(PN71xxTransceive, SkipsNotificationAheadOfResponse) { + FakePN71xx nfcc; + nfcc.to_read.push_back({0x61, 0x06, 0x02, 0x00, 0x00}); + nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x06, 0x01, 0x00})); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +TEST(PN71xxTransceive, NotificationAloneIsNotAResponse) { + FakePN71xx nfcc; + nfcc.to_read.push_back({0x61, 0x06, 0x02, 0x00, 0x00}); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_NE(nfcc.transceive_(tx, rx), nfc::STATUS_OK); +} + +// A late response to an earlier, timed-out command must not be taken as the response to this one. +TEST(PN71xxTransceive, SkipsStaleResponseFromEarlierCommand) { + FakePN71xx nfcc; + nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); // RF_DEACTIVATE_RSP, arriving late + nfcc.to_read.push_back({0x41, 0x03, 0x01, 0x00}); // RF_DISCOVER_RSP + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x03, 0x01, 0x00})); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +nfc::NfcTagUid uid_of(uint8_t last) { return {0x04, 0x00, 0x00, last}; } + +// Erasing an entry keeps the others in order and frees the tag of the slot that is dropped. +TEST(PN71xxTagCache, EraseKeepsOrderAndFreesTail) { + FakePN71xx nfcc; + for (uint8_t i = 0; i < 3; i++) { + nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); + } + nfcc.erase_tag_(1); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), 2u); + EXPECT_EQ(nfcc.discovered_endpoint_[0].tag->get_uid()[3], 0); + EXPECT_EQ(nfcc.discovered_endpoint_[1].tag->get_uid()[3], 2); + EXPECT_EQ(nfcc.discovered_endpoint_.data()[2].tag, nullptr); + nfcc.erase_tag_(1); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), 1u); + EXPECT_EQ(nfcc.discovered_endpoint_.data()[1].tag, nullptr); +} + +// A full cache evicts the entry seen longest ago instead of refusing the new tag. +TEST(PN71xxTagCache, FullCacheEvictsOldest) { + FakePN71xx nfcc; + for (uint8_t i = 0; i < MAX_DISCOVERED_ENDPOINTS; i++) { + const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); + nfcc.discovered_endpoint_[loc].last_seen = 100 + i; + } + nfcc.discovered_endpoint_[3].last_seen = 1; // seen longest ago + const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); + EXPECT_EQ(nfcc.discovered_endpoint_[loc].tag->get_uid()[3], 0x99); + for (const auto &endpoint : nfcc.discovered_endpoint_) { + EXPECT_NE(endpoint.tag->get_uid()[3], 3); + } + // a known UID is found, not added again + EXPECT_EQ(nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)), loc); + EXPECT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); +} + +// Bytes that do not fit the packet are dropped and the length byte stays consistent. +TEST(PN71xxNciMessage, AppendStopsAtPacketSize) { + nfc::NciMessage msg(nfc::NCI_PKT_MT_DATA, {0x01}); + std::vector big(300, 0xAA); + msg.append(big); + const auto encoded = msg.encode(); + EXPECT_EQ(encoded.size(), nfc::NCI_PKT_MAX_SIZE); + EXPECT_EQ(msg.get_payload_size(), nfc::NCI_PKT_MAX_PAYLOAD_SIZE); +} + +// A read that could not fit the status bytes into one packet is refused. +TEST(PN71xxCardEmulation, OversizedReadIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/pulse_ce"); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0xFE}), SW_NOT_FOUND); +} + +// A refused write (e.g. NFCC in standby) is sent again. +TEST(PN71xxTransceive, RefusedWriteIsRetried) { + FakePN71xx nfcc; + nfcc.write_failures = 1; + nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +TEST(PN71xxCardEmulation, CcReadOutOfRangeIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); + // the CC file is 15 bytes; reading 17 or reading far past its end must not return memory beyond it + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x11}), SW_NOT_FOUND); + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x01, 0x00, 0x0F}), SW_NOT_FOUND); +} + +TEST(PN71xxCardEmulation, CcReadInRange) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); + auto response = respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x0F}); + ASSERT_EQ(response.size(), sizeof(CARD_EMU_T4T_CC) + 2); + EXPECT_TRUE(std::equal(std::begin(CARD_EMU_T4T_CC), std::end(CARD_EMU_T4T_CC), response.begin())); +} + +// Reading the NDEF file in small chunks returns NLEN followed by the message, in order. +TEST(PN71xxCardEmulation, ChunkedNdefReadMatchesFile) { + FakePN71xx nfcc; + auto message = std::make_shared(); + message->add_uri_record("https://www.home-assistant.io/tag/0123456789abcdef"); + const auto encoded = message->encode(); + nfcc.set_tag_emulation_message(message); + select_ndef_file(nfcc); + + std::vector expected = {static_cast(encoded.size() >> 8), + static_cast(encoded.size() & 0xFF)}; + expected.insert(expected.end(), encoded.begin(), encoded.end()); + + std::vector file; + for (size_t offset = 0; offset < expected.size(); offset += 5) { + const uint8_t length = std::min(5, expected.size() - offset); + auto response = + respond(nfcc, {0x00, 0xB0, static_cast(offset >> 8), static_cast(offset), length}); + ASSERT_EQ(response.size(), length + 2u); + EXPECT_EQ(std::vector(response.end() - 2, response.end()), SW_OK); + file.insert(file.end(), response.begin(), response.end() - 2); + } + EXPECT_EQ(file, expected); +} + +TEST(PN71xxCardEmulation, NdefReadPastEndIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x02, 0xFD}), SW_NOT_FOUND); +} + +TEST(PN71xxCardEmulation, TruncatedApdusAreRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00}), SW_NOT_FOUND); + select_ndef_file(nfcc); + // UPDATE BINARY claiming 16 bytes of data but carrying only 2 + EXPECT_EQ(respond(nfcc, {0x00, 0xD6, 0x00, 0x00, 0x10, 0x00, 0x00}), SW_NOT_FOUND); +} + +// A message too large for the emulated NDEF file is refused when it is set, keeping the previous one. +TEST(PN71xxCardEmulation, OversizedMessageRejectedWhenSet) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/" + std::string(300, 'x'), false); + select_ndef_file(nfcc); + auto response = respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x02}); + ASSERT_EQ(response.size(), 4u); + EXPECT_LT((response[0] << 8) | response[1], 0xFF - 2); + EXPECT_EQ(std::vector(response.end() - 2, response.end()), SW_OK); +} + +} // namespace esphome::pn71xx diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml index baa3aa8f683..4a34539002e 100644 --- a/tests/components/provisioning/test.esp32-idf.yaml +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -1,6 +1,6 @@ # Exercises the provisioning window: api registers as a provisioning source # (encryption enabled, no key), the on_timeout automation, and the wifi (AP + -# captive portal) and esp32_improv cross-component guards. improv_serial is +# captive portal) and improv_ble cross-component guards. improv_serial is # intentionally NOT gated. provisioning: timeout: 1min @@ -26,5 +26,5 @@ binary_sensor: pin: 0 id: io0_button -esp32_improv: +improv_ble: authorizer: io0_button diff --git a/tests/components/pulse_counter/common.yaml b/tests/components/pulse_counter/common.yaml index 556b43ee6fa..2b27d5467b0 100644 --- a/tests/components/pulse_counter/common.yaml +++ b/tests/components/pulse_counter/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: pulse_counter + id: pulse_counter_sensor name: Pulse Counter pin: 4 count_mode: @@ -7,3 +8,10 @@ sensor: falling_edge: DECREMENT internal_filter: 13us update_interval: 15s + on_value: + - pulse_counter.set_total_pulses: + id: pulse_counter_sensor + value: 12345 + - pulse_counter.set_total_pulses: + id: pulse_counter_sensor + value: !lambda return (uint32_t) x; diff --git a/tests/components/pulse_meter/common.yaml b/tests/components/pulse_meter/common.yaml index a83ec478bb1..8721f2e375f 100644 --- a/tests/components/pulse_meter/common.yaml +++ b/tests/components/pulse_meter/common.yaml @@ -9,5 +9,8 @@ sensor: - pulse_meter.set_total_pulses: id: pulse_meter_sensor value: 12345 + - pulse_meter.set_total_pulses: + id: pulse_meter_sensor + value: !lambda return (uint32_t) x; total: name: Pulse Meter Total diff --git a/tests/components/pzem6l24/common.h b/tests/components/pzem6l24/common.h new file mode 100644 index 00000000000..5ceab795179 --- /dev/null +++ b/tests/components/pzem6l24/common.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/pzem6l24/pzem6l24.h" + +namespace esphome::pzem6l24::testing { + +// The request PDU update() puts on the wire: read 64 input registers from 0x0000. +static constexpr uint8_t READ_REQUEST_PDU[] = {0x04, 0x00, 0x00, 0x00, PZEM_REGISTER_COUNT}; +// The request PDU reset_energy() puts on the wire for every phase. +static constexpr auto RESET_REQUEST_PDU = build_reset_pdu(RESET_PHASE_ALL); + +// Builds the 128-byte register payload the meter returns, writing each quantity in the +// little-endian byte order documented in pzem6l24.cpp's register map. +class PayloadBuilder { + public: + PayloadBuilder &u8(size_t offset, uint8_t value) { + this->data_[offset] = value; + return *this; + } + PayloadBuilder &u16(size_t offset, uint16_t value) { + this->data_[offset] = static_cast(value & 0xFF); + this->data_[offset + 1] = static_cast(value >> 8); + return *this; + } + PayloadBuilder &u32(size_t offset, uint32_t value) { + this->u16(offset, static_cast(value & 0xFFFF)); + this->u16(offset + 2, static_cast(value >> 16)); + return *this; + } + PayloadBuilder &i32(size_t offset, int32_t value) { return this->u32(offset, static_cast(value)); } + + // Wraps the payload in a read-input-registers response PDU: function code, byte count, data. + std::vector response_pdu() const { + std::vector pdu{0x04, static_cast(PZEM_PAYLOAD_SIZE)}; + pdu.insert(pdu.end(), this->data_.begin(), this->data_.end()); + return pdu; + } + + protected: + std::array data_{}; +}; + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/common.yaml b/tests/components/pzem6l24/common.yaml new file mode 100644 index 00000000000..25cb86cc84f --- /dev/null +++ b/tests/components/pzem6l24/common.yaml @@ -0,0 +1,82 @@ +esphome: + on_boot: + then: + - pzem6l24.reset_energy: pzem6l24_1 + - pzem6l24.reset_energy: + id: pzem6l24_1 + phase: b + +sensor: + - platform: pzem6l24 + modbus_id: modbus_bus + id: pzem6l24_1 + voltage_a: + name: PZEM6L24 Voltage A + voltage_b: + name: PZEM6L24 Voltage B + voltage_c: + name: PZEM6L24 Voltage C + current_a: + name: PZEM6L24 Current A + current_b: + name: PZEM6L24 Current B + current_c: + name: PZEM6L24 Current C + active_power_a: + name: PZEM6L24 Active Power A + active_power_b: + name: PZEM6L24 Active Power B + active_power_c: + name: PZEM6L24 Active Power C + reactive_power_a: + name: PZEM6L24 Reactive Power A + reactive_power_b: + name: PZEM6L24 Reactive Power B + reactive_power_c: + name: PZEM6L24 Reactive Power C + apparent_power_a: + name: PZEM6L24 Apparent Power A + apparent_power_b: + name: PZEM6L24 Apparent Power B + apparent_power_c: + name: PZEM6L24 Apparent Power C + power_factor_a: + name: PZEM6L24 Power Factor A + power_factor_b: + name: PZEM6L24 Power Factor B + power_factor_c: + name: PZEM6L24 Power Factor C + active_energy_a: + name: PZEM6L24 Active Energy A + active_energy_b: + name: PZEM6L24 Active Energy B + active_energy_c: + name: PZEM6L24 Active Energy C + reactive_energy_a: + name: PZEM6L24 Reactive Energy A + reactive_energy_b: + name: PZEM6L24 Reactive Energy B + reactive_energy_c: + name: PZEM6L24 Reactive Energy C + apparent_energy_a: + name: PZEM6L24 Apparent Energy A + apparent_energy_b: + name: PZEM6L24 Apparent Energy B + apparent_energy_c: + name: PZEM6L24 Apparent Energy C + frequency: + name: PZEM6L24 Frequency + total_active_power: + name: PZEM6L24 Total Active Power + total_reactive_power: + name: PZEM6L24 Total Reactive Power + total_apparent_power: + name: PZEM6L24 Total Apparent Power + total_power_factor: + name: PZEM6L24 Total Power Factor + total_active_energy: + name: PZEM6L24 Total Active Energy + total_reactive_energy: + name: PZEM6L24 Total Reactive Energy + total_apparent_energy: + name: PZEM6L24 Total Apparent Energy diff --git a/tests/components/pzem6l24/sensor/pzem6l24_test.cpp b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp new file mode 100644 index 00000000000..6dbb4ebf02a --- /dev/null +++ b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp @@ -0,0 +1,315 @@ +#include "../common.h" + +#include +#include +#include + +namespace esphome::pzem6l24::testing { + +namespace { + +// A payload whose raw values are distinct per quantity, so a transposed offset shows up as a wrong +// value rather than a coincidental match. Registers the component does not read are filled with 0xEE. +PayloadBuilder make_reference_payload() { + PayloadBuilder p; + // Voltages (×0.1 V) + p.u16(0, 2301).u16(2, 2302).u16(4, 2303); + // Currents (×0.01 A) + p.u16(6, 1234).u16(8, 1235).u16(10, 1236); + // Frequency phase A (×0.01 Hz); phases B/C and the angle registers are not read. + p.u16(12, 5001); + for (size_t offset = 14; offset < 28; offset++) { + p.u8(offset, 0xEE); + } + // Active power (×0.1 W, signed); phase C exercises the sign extension. + p.i32(28, 15001).i32(32, 15002).i32(36, -15003).i32(64, 45006); + // Reactive power (×0.1 var, signed) + p.i32(40, 2001).i32(44, 2002).i32(48, 2003).i32(68, 6006); + // Apparent power (×0.1 VA, signed) + p.i32(52, 3001).i32(56, 3002).i32(60, 3003).i32(72, 9006); + // Power factors (×0.01), packed two per register: 77=A, 76=B, 79=C, 78=combined + p.u8(77, 98).u8(76, 97).u8(79, 96).u8(78, 95); + // Active energy (×0.1 kWh) + p.u32(80, 100001).u32(84, 100002).u32(88, 100003).u32(116, 300006); + // Reactive energy (×0.1 kvarh) + p.u32(92, 200001).u32(96, 200002).u32(100, 200003).u32(120, 600006); + // Apparent energy (×0.1 kVAh); phase A exceeds 16 bits to exercise the high word. + p.u32(104, 300001).u32(108, 300002).u32(112, 300003).u32(124, 900006); + return p; +} + +// One PZEM6L24 with every sensor it can drive attached, so a test can assert that each byte offset in +// the register map reaches the sensor it is documented to feed. +struct Harness { + PZEM6L24 pzem; + + sensor::Sensor voltage_a, voltage_b, voltage_c; + sensor::Sensor current_a, current_b, current_c; + sensor::Sensor active_power_a, active_power_b, active_power_c; + sensor::Sensor reactive_power_a, reactive_power_b, reactive_power_c; + sensor::Sensor apparent_power_a, apparent_power_b, apparent_power_c; + sensor::Sensor power_factor_a, power_factor_b, power_factor_c; + sensor::Sensor active_energy_a, active_energy_b, active_energy_c; + sensor::Sensor reactive_energy_a, reactive_energy_b, reactive_energy_c; + sensor::Sensor apparent_energy_a, apparent_energy_b, apparent_energy_c; + sensor::Sensor frequency; + sensor::Sensor total_active_power, total_reactive_power, total_apparent_power; + sensor::Sensor total_power_factor; + sensor::Sensor total_active_energy, total_reactive_energy, total_apparent_energy; + + Harness() { + this->pzem.set_voltage_a_sensor(&this->voltage_a); + this->pzem.set_voltage_b_sensor(&this->voltage_b); + this->pzem.set_voltage_c_sensor(&this->voltage_c); + this->pzem.set_current_a_sensor(&this->current_a); + this->pzem.set_current_b_sensor(&this->current_b); + this->pzem.set_current_c_sensor(&this->current_c); + this->pzem.set_active_power_a_sensor(&this->active_power_a); + this->pzem.set_active_power_b_sensor(&this->active_power_b); + this->pzem.set_active_power_c_sensor(&this->active_power_c); + this->pzem.set_reactive_power_a_sensor(&this->reactive_power_a); + this->pzem.set_reactive_power_b_sensor(&this->reactive_power_b); + this->pzem.set_reactive_power_c_sensor(&this->reactive_power_c); + this->pzem.set_apparent_power_a_sensor(&this->apparent_power_a); + this->pzem.set_apparent_power_b_sensor(&this->apparent_power_b); + this->pzem.set_apparent_power_c_sensor(&this->apparent_power_c); + this->pzem.set_power_factor_a_sensor(&this->power_factor_a); + this->pzem.set_power_factor_b_sensor(&this->power_factor_b); + this->pzem.set_power_factor_c_sensor(&this->power_factor_c); + this->pzem.set_active_energy_a_sensor(&this->active_energy_a); + this->pzem.set_active_energy_b_sensor(&this->active_energy_b); + this->pzem.set_active_energy_c_sensor(&this->active_energy_c); + this->pzem.set_reactive_energy_a_sensor(&this->reactive_energy_a); + this->pzem.set_reactive_energy_b_sensor(&this->reactive_energy_b); + this->pzem.set_reactive_energy_c_sensor(&this->reactive_energy_c); + this->pzem.set_apparent_energy_a_sensor(&this->apparent_energy_a); + this->pzem.set_apparent_energy_b_sensor(&this->apparent_energy_b); + this->pzem.set_apparent_energy_c_sensor(&this->apparent_energy_c); + this->pzem.set_frequency_sensor(&this->frequency); + this->pzem.set_total_active_power_sensor(&this->total_active_power); + this->pzem.set_total_reactive_power_sensor(&this->total_reactive_power); + this->pzem.set_total_apparent_power_sensor(&this->total_apparent_power); + this->pzem.set_total_power_factor_sensor(&this->total_power_factor); + this->pzem.set_total_active_energy_sensor(&this->total_active_energy); + this->pzem.set_total_reactive_energy_sensor(&this->total_reactive_energy); + this->pzem.set_total_apparent_energy_sensor(&this->total_apparent_energy); + } + + // A good poll, as the hub would deliver it. + void poll_ok() { this->pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); } +}; + +// A good poll followed by MAX_CONSECUTIVE_READ_FAILURES calls of `fail` must blank every reading. +template void expect_blanked_after_repeated(Harness &h, F &&fail) { + h.poll_ok(); + ASSERT_FALSE(std::isnan(h.voltage_a.state)); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + fail(); + } + EXPECT_TRUE(std::isnan(h.voltage_a.state)); + EXPECT_TRUE(std::isnan(h.total_active_energy.state)); +} + +} // namespace + +TEST(PZEM6L24Test, DecodesEveryRegisterToItsSensor) { + Harness h; + const auto response = make_reference_payload().response_pdu(); + + h.pzem.on_response(READ_REQUEST_PDU, response); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_b.state, 2302 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_c.state, 2303 * 0.1f); + + EXPECT_FLOAT_EQ(h.current_a.state, 1234 * 0.01f); + EXPECT_FLOAT_EQ(h.current_b.state, 1235 * 0.01f); + EXPECT_FLOAT_EQ(h.current_c.state, 1236 * 0.01f); + + EXPECT_FLOAT_EQ(h.frequency.state, 5001 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_power_a.state, 15001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_b.state, 15002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_c.state, -15003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_power.state, 45006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_power_a.state, 2001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_b.state, 2002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_c.state, 2003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_power.state, 6006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_power_a.state, 3001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_b.state, 3002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_c.state, 3003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_power.state, 9006 * 0.1f); + + EXPECT_FLOAT_EQ(h.power_factor_a.state, 98 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_b.state, 97 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_c.state, 96 * 0.01f); + EXPECT_FLOAT_EQ(h.total_power_factor.state, 95 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_energy_a.state, 100001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_b.state, 100002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_c.state, 100003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_energy_a.state, 200001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_b.state, 200002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_c.state, 200003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_energy.state, 600006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_energy_a.state, 300001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_b.state, 300002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_c.state, 300003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_energy.state, 900006 * 0.1f); +} + +// Unconfigured sensors must be skipped rather than dereferenced. +TEST(PZEM6L24Test, PublishesOnlyConfiguredSensors) { + PZEM6L24 pzem; + sensor::Sensor voltage_a; + pzem.set_voltage_a_sensor(&voltage_a); + + pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); + + EXPECT_TRUE(voltage_a.has_state()); + EXPECT_FLOAT_EQ(voltage_a.state, 2301 * 0.1f); +} + +// The acknowledgement of the 0x42 reset command carries no measurements and must not be decoded. +TEST(PZEM6L24Test, IgnoresResetAcknowledgement) { + Harness h; + const uint8_t ack_pdu[] = {0x42, 0x00, 0x0F}; + + h.pzem.on_response(RESET_REQUEST_PDU, ack_pdu); + + EXPECT_FALSE(h.voltage_a.has_state()); + EXPECT_FALSE(h.total_active_energy.has_state()); +} + +// A truncated response must be rejected rather than decoded from out-of-range bytes. +TEST(PZEM6L24Test, PublishesNanOnShortPayload) { + Harness h; + std::vector short_pdu{0x04, 10}; + short_pdu.resize(12, 0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, short_pdu); }); +} + +// A byte-count-0 reply the hub still dispatches is as undecodable as any other wrong size. +TEST(PZEM6L24Test, PublishesNanOnEmptyPayload) { + Harness h; + const uint8_t empty_pdu[] = {0x04, 0x00}; + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, empty_pdu); }); +} + +// A response longer than the register map did not come from the expected frame layout. +TEST(PZEM6L24Test, PublishesNanOnOversizedPayload) { + Harness h; + auto long_pdu = make_reference_payload().response_pdu(); + long_pdu.push_back(0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, long_pdu); }); +} + +TEST(PZEM6L24Test, PublishesNanWhenTheMeterDoesNotRespond) { + Harness h; + expect_blanked_after_repeated(h, [&] { EXPECT_FALSE(h.pzem.on_no_response(READ_REQUEST_PDU)); }); +} + +TEST(PZEM6L24Test, PublishesNanOnExceptionResponse) { + Harness h; + expect_blanked_after_repeated( + h, [&] { h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); }); +} + +// A read dropped from the transmit queue never reaches the meter. +TEST(PZEM6L24Test, PublishesNanWhenTheReadIsNotSent) { + Harness h; + expect_blanked_after_repeated(h, [&] { h.pzem.on_not_sent(READ_REQUEST_PDU); }); +} + +// Readings ride out isolated failures, and a good poll restarts the count. +TEST(PZEM6L24Test, KeepsReadingsUntilFailuresReachTheThreshold) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_no_response(READ_REQUEST_PDU); + } + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + + h.poll_ok(); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::SERVICE_DEVICE_FAILURE); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); +} + +// update()'s refusal branch is pinned against a real hub in both directions. +TEST(PZEM6L24Test, KeepsReadingsWhenAPollIsAbsorbedIntoAReadInFlight) { + // Declared before the harness so it outlives it: ~ModbusClientDevice clears its frames from the hub. + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + h.poll_ok(); + + // A read entry serves at most two requests; the third poll is refused while two callbacks are owed. + h.pzem.update(); + h.pzem.update(); + h.pzem.update(); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// A refusal with nothing in flight means no callback is coming, so it counts as a failed poll. +TEST(PZEM6L24Test, PublishesNanWhenThePollCannotBeQueued) { + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + + // Fill the transmit queue with frames for another address, so the poll is refused rather than absorbed. + for (uint16_t i = 0; i < modbus::MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t filler_pdu[] = {0x04, 0x00, static_cast(i), 0x00, 0x01}; + ASSERT_TRUE(hub.queue_pdu(0x02, filler_pdu)); + } + + expect_blanked_after_repeated(h, [&] { h.pzem.update(); }); +} + +// A failed reset command says nothing about the measurements, so it must not blank them. +TEST(PZEM6L24Test, KeepsReadingsWhenTheResetCommandFails) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + EXPECT_FALSE(h.pzem.on_no_response(RESET_REQUEST_PDU)); + h.pzem.on_error(RESET_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_FUNCTION); + h.pzem.on_not_sent(RESET_REQUEST_PDU); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// The reset is irreversible, so the phase selector byte is pinned here. +TEST(PZEM6L24Test, BuildsTheResetFrameForEveryPhase) { + const std::array, 5> cases{{ + {RESET_PHASE_A, 0x00}, + {RESET_PHASE_B, 0x01}, + {RESET_PHASE_C, 0x02}, + {RESET_PHASE_COMBINED, 0x03}, + {RESET_PHASE_ALL, 0x0F}, + }}; + + for (const auto &[phase, selector] : cases) { + const auto pdu = build_reset_pdu(phase); + EXPECT_EQ(pdu[0], 0x42) << "function code for selector " << static_cast(selector); + EXPECT_EQ(pdu[1], 0x00) << "reserved byte for selector " << static_cast(selector); + EXPECT_EQ(pdu[2], selector); + } +} + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/test.esp32-idf.yaml b/tests/components/pzem6l24/test.esp32-idf.yaml new file mode 100644 index 00000000000..9fb11cd095c --- /dev/null +++ b/tests/components/pzem6l24/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO13 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.esp8266-ard.yaml b/tests/components/pzem6l24/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e2b6a4d58f6 --- /dev/null +++ b/tests/components/pzem6l24/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.rp2040-ard.yaml b/tests/components/pzem6l24/test.rp2040-ard.yaml new file mode 100644 index 00000000000..e2b164efd25 --- /dev/null +++ b/tests/components/pzem6l24/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/qmi8658/common-actions.yaml b/tests/components/qmi8658/common-actions.yaml new file mode 100644 index 00000000000..b8ab845ef11 --- /dev/null +++ b/tests/components/qmi8658/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Clear motion calibration + on_press: + - motion.clear_calibration: + - motion.clear_calibration: + save: true diff --git a/tests/components/qmi8658/test-actions.esp8266-ard.yaml b/tests/components/qmi8658/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..28421ac7e69 --- /dev/null +++ b/tests/components/qmi8658/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + qmi8658: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py new file mode 100644 index 00000000000..714f8ab571a --- /dev/null +++ b/tests/components/remote_base/__init__.py @@ -0,0 +1,14 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + async def to_code_testing(config: ConfigType) -> None: + # Protocol sources are compiled only behind their define; keep the ones under test. + request_protocol("hob2hood") + + manifest.to_code = to_code_testing + # AUTO_LOAD is not resolved by the unit test build. + manifest.dependencies = manifest.dependencies + ["binary_sensor"] diff --git a/tests/components/remote_base/hob2hood_test.cpp b/tests/components/remote_base/hob2hood_test.cpp new file mode 100644 index 00000000000..223e15fe495 --- /dev/null +++ b/tests/components/remote_base/hob2hood_test.cpp @@ -0,0 +1,83 @@ +#include +#include "esphome/components/remote_base/hob2hood_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr std::array ALL_COMMANDS = { + HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW, + HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX, +}; + +RawTimings encode(Hob2HoodCommand command) { + RemoteTransmitData data; + Hob2HoodProtocol().encode(&data, Hob2HoodData{command}); + return data.get_data(); +} + +optional decode(const RawTimings &timings, uint32_t tolerance = 25, + ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) { + return Hob2HoodProtocol().decode(RemoteReceiveData(timings, tolerance, mode)); +} + +void expect_decodes_to(const RawTimings &timings, Hob2HoodCommand command, uint32_t tolerance = 25, + ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) { + auto decoded = decode(timings, tolerance, mode); + ASSERT_TRUE(decoded.has_value()) << "command 0x" << std::hex << int(command); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->command, command); + } +} + +} // namespace + +// light_on is 0xd2: the bits 0 11010010 11010011 11010100 form 17 runs. A run of n zero bits is a mark of +// n * 700 + 300 us; a run of n one bits is a space of n * 700 - 200 us. +TEST(Hob2HoodProtocolTest, EncodesTheDocumentedTimings) { + const RawTimings expected = {1000, -1200, 1000, -500, 1700, -500, 1000, -1200, 1000, + -500, 1700, -2600, 1000, -500, 1000, -500, 1700}; + EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_ON), expected); +} + +TEST(Hob2HoodProtocolTest, LongestFrameFitsTheReservedLength) { + for (auto command : ALL_COMMANDS) { + EXPECT_LE(encode(command).size(), 18u) << "command 0x" << std::hex << int(command); + } + EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_OFF).size(), 18u); +} + +TEST(Hob2HoodProtocolTest, RoundTripsEveryCommand) { + for (auto command : ALL_COMMANDS) { + expect_decodes_to(encode(command), command); + } +} + +// A receiver never captures the trailing space, so a frame that ends in one must still decode without it. +TEST(Hob2HoodProtocolTest, DecodesWithoutTheTrailingSpace) { + for (auto command : ALL_COMMANDS) { + auto timings = encode(command); + if (timings.back() < 0) + timings.pop_back(); + expect_decodes_to(timings, command); + } +} + +// Real receivers shorten marks and lengthen spaces by a couple of hundred microseconds, which is why the +// documentation recommends a 350us tolerance. +TEST(Hob2HoodProtocolTest, DecodesSkewedTimingsWithinTheRecommendedTolerance) { + auto timings = encode(HOB2HOOD_COMMAND_FAN_HIGH); + for (auto &t : timings) { + t += t > 0 ? -240 : -230; + } + expect_decodes_to(timings, HOB2HOOD_COMMAND_FAN_HIGH, 350, TOLERANCE_MODE_TIME); +} + +TEST(Hob2HoodProtocolTest, RejectsAForeignFrame) { + const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560}; + EXPECT_FALSE(decode(nec_like).has_value()); + EXPECT_FALSE(decode({}).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/bare-common.yaml b/tests/components/remote_receiver/bare-common.yaml new file mode 100644 index 00000000000..c100c5c2da4 --- /dev/null +++ b/tests/components/remote_receiver/bare-common.yaml @@ -0,0 +1,6 @@ +# A receiver with no dumpers and no listeners compiles both lists out. +# Only built while remote_receiver is tested in isolation: the counts are global defines, +# so this variant cannot be merged with configs that register any. +remote_receiver: + - id: rcvr_bare + pin: ${pin} diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 26a02d4dabc..5d12dc68d43 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -172,3 +172,8 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_hob2hood: + then: + - logger.log: + format: "on_hob2hood: %u" + args: ["x.command"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 14effcbd2c5..956cfdc3233 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,3 +15,7 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: Hob2Hood Light On + hob2hood: + command: light_on diff --git a/tests/components/remote_receiver/test-bare.esp32-idf.yaml b/tests/components/remote_receiver/test-bare.esp32-idf.yaml new file mode 100644 index 00000000000..152853b65fb --- /dev/null +++ b/tests/components/remote_receiver/test-bare.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + pin: GPIO2 + +packages: + bare: !include bare-common.yaml diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 981946a9a4c..a41ba346aa1 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -275,3 +275,11 @@ button: - remote_transmitter.digital_write: true - remote_transmitter.digital_write: value: false + - remote_transmitter.digital_write: + transmitter_id: xmitr + value: !lambda return id(remote_transmitter_test_number).state > 127; + - platform: template + name: Hob2Hood + on_press: + - remote_transmitter.transmit_hob2hood: + command: light_on diff --git a/tests/components/rf_bridge/common.yaml b/tests/components/rf_bridge/common.yaml index 427c3d783d7..2ad0c4fa9db 100644 --- a/tests/components/rf_bridge/common.yaml +++ b/tests/components/rf_bridge/common.yaml @@ -27,3 +27,6 @@ rf_bridge: code: "ABC123" - rf_bridge.send_raw: raw: "AAA5070008001000ABC12355" + - rf_bridge.start_bucket_sniffing: + - rf_bridge.beep: + duration: !lambda return 100; diff --git a/tests/components/rotary_encoder/common.yaml b/tests/components/rotary_encoder/common.yaml index 57ffdab8178..668cfac0e27 100644 --- a/tests/components/rotary_encoder/common.yaml +++ b/tests/components/rotary_encoder/common.yaml @@ -18,7 +18,7 @@ sensor: value: 10 - sensor.rotary_encoder.set_value: id: rotary_encoder1 - value: !lambda "return -1;" + value: !lambda "return -static_cast(x);" on_clockwise: - logger.log: Clockwise on_anticlockwise: diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 2970a48afbe..b857a7e76fb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,3 +1,13 @@ +esphome: + on_boot: + then: + - output.rp2040_pwm.set_frequency: + id: rp2040_pwm_light_output_1 + frequency: 100Hz + - output.rp2040_pwm.set_frequency: + id: rp2040_pwm_light_output_2 + frequency: !lambda return 200.0f; + output: - platform: rp2040_pwm id: rp2040_pwm_light_output_1 diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index a4d8f951f42..87c1eebdf0d 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -19,6 +19,19 @@ esphome: - rtttl.play: 'test_only_o:o=5:c' - rtttl.play: 'test_only_b:b=100:c' - rtttl.play: 'test_empty::c' + # Long form with an explicit id, a lambda song, and the is_playing condition + - rtttl.play: + id: rtttl_player + rtttl: 'test_long_form:d=8,o=5,b=100:c' + - rtttl.play: + rtttl: !lambda return std::string("test_lambda:d=8,o=5,b=100:c"); + - if: + condition: + rtttl.is_playing: + id: rtttl_player + then: + - rtttl.stop: + id: rtttl_player output: - platform: ${output_platform} @@ -28,6 +41,7 @@ output: max_power: 0.5 rtttl: + id: rtttl_player output: rtttl_output on_finished_playback: - then: diff --git a/tests/components/runtime_image/test_format_names.cpp b/tests/components/runtime_image/test_format_names.cpp new file mode 100644 index 00000000000..16299a60896 --- /dev/null +++ b/tests/components/runtime_image/test_format_names.cpp @@ -0,0 +1,19 @@ +#include + +#include "esphome/components/runtime_image/image_format.h" + +namespace esphome::runtime_image::testing { + +TEST(RuntimeImageFormatNames, FormatNames) { + EXPECT_STREQ(LOG_STR_ARG(get_format_name(AUTO)), "AUTO"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(BMP)), "BMP"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(JPEG)), "JPEG"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(PNG)), "PNG"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(QOI)), "QOI"); +} + +TEST(RuntimeImageFormatNames, OutOfRangeName) { + EXPECT_STREQ(LOG_STR_ARG(get_format_name(static_cast(254))), "UNKNOWN"); +} + +} // namespace esphome::runtime_image::testing diff --git a/tests/components/runtime_image/test_mime_types.cpp b/tests/components/runtime_image/test_mime_types.cpp index 22b825cff08..5f702d36aff 100644 --- a/tests/components/runtime_image/test_mime_types.cpp +++ b/tests/components/runtime_image/test_mime_types.cpp @@ -2,7 +2,7 @@ #include -#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/runtime_image/image_format.h" namespace esphome::runtime_image::testing { diff --git a/tests/components/rx8025t/common.yaml b/tests/components/rx8025t/common.yaml new file mode 100644 index 00000000000..b8c7b6a1a2f --- /dev/null +++ b/tests/components/rx8025t/common.yaml @@ -0,0 +1,12 @@ +esphome: + on_boot: + then: + - rx8025t.write_time: rx8025t_time + - rx8025t.read_time: + id: rx8025t_time + +time: + - platform: rx8025t + i2c_id: i2c_bus + id: rx8025t_time + update_interval: never diff --git a/tests/components/rx8025t/test.esp32-idf.yaml b/tests/components/rx8025t/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/rx8025t/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.esp8266-ard.yaml b/tests/components/rx8025t/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/rx8025t/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.nrf52-adafruit.yaml b/tests/components/rx8025t/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..2a0de6241c3 --- /dev/null +++ b/tests/components/rx8025t/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.rp2040-ard.yaml b/tests/components/rx8025t/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/rx8025t/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/safe_mode/common-enabled.yaml b/tests/components/safe_mode/common-enabled.yaml index 43025c60dbd..b2a10b6d93f 100644 --- a/tests/components/safe_mode/common-enabled.yaml +++ b/tests/components/safe_mode/common-enabled.yaml @@ -3,6 +3,7 @@ wifi: password: password1 safe_mode: + id: safe_mode_1 boot_is_good_after: 2min num_attempts: 3 reboot_timeout: 2min @@ -20,3 +21,5 @@ switch: esphome: on_boot: - safe_mode.mark_successful + - safe_mode.mark_successful: + id: safe_mode_1 diff --git a/tests/components/scd30/common-actions.yaml b/tests/components/scd30/common-actions.yaml new file mode 100644 index 00000000000..ed88e566da3 --- /dev/null +++ b/tests/components/scd30/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: SCD30 Calibrate + on_press: + - scd30.force_recalibration_with_reference: 419 + - scd30.force_recalibration_with_reference: + id: co2_scd30 + value: !lambda return 420; diff --git a/tests/components/scd30/common.yaml b/tests/components/scd30/common.yaml index f21d8944dce..4d15e83c653 100644 --- a/tests/components/scd30/common.yaml +++ b/tests/components/scd30/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: scd30 + id: co2_scd30 i2c_id: i2c_bus co2: name: SCD30 CO2 diff --git a/tests/components/scd30/test-actions.esp8266-ard.yaml b/tests/components/scd30/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..eccbb90cc76 --- /dev/null +++ b/tests/components/scd30/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + scd30: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/scd4x/common-actions.yaml b/tests/components/scd4x/common-actions.yaml new file mode 100644 index 00000000000..20c30e2cada --- /dev/null +++ b/tests/components/scd4x/common-actions.yaml @@ -0,0 +1,11 @@ +button: + - platform: template + name: SCD4x Actions + on_press: + - scd4x.perform_forced_calibration: + id: scd40 + value: 419 + - scd4x.perform_forced_calibration: + id: scd40 + value: !lambda return 420; + - scd4x.factory_reset: scd40 diff --git a/tests/components/scd4x/test-actions.esp8266-ard.yaml b/tests/components/scd4x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c5bf817cb86 --- /dev/null +++ b/tests/components/scd4x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + scd4x: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index f4818e22962..6b7290277a1 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -15,6 +15,8 @@ esphome: strings: ["a", "b"] - script.wait: my_script - script.stop: my_script + - script.stop: + id: my_script_queued - if: condition: - script.is_running: my_script diff --git a/tests/components/select/common.yaml b/tests/components/select/common.yaml new file mode 100644 index 00000000000..f20c024cb8c --- /dev/null +++ b/tests/components/select/common.yaml @@ -0,0 +1,43 @@ +select: + - platform: template + name: "Test Select" + id: select_test_select + optimistic: true + options: + - one + - two + - three + initial_option: two + +button: + - platform: template + name: "Test Select Actions" + on_press: + - select.set: + id: select_test_select + option: three + - select.set: + id: select_test_select + option: !lambda |- + std::string option = "one"; + return option; + - select.set_index: + id: select_test_select + index: 1 + - select.set_index: + id: select_test_select + index: !lambda return 2; + - select.next: select_test_select + - select.previous: + id: select_test_select + cycle: false + - select.first: select_test_select + - select.last: select_test_select + - select.operation: + id: select_test_select + operation: next + cycle: false + - select.operation: + id: select_test_select + operation: !lambda return SELECT_OP_PREVIOUS; + cycle: !lambda return true; diff --git a/tests/components/select/test.esp32-idf.yaml b/tests/components/select/test.esp32-idf.yaml new file mode 100644 index 00000000000..25cb37a0b42 --- /dev/null +++ b/tests/components/select/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/select/test.esp8266-ard.yaml b/tests/components/select/test.esp8266-ard.yaml new file mode 100644 index 00000000000..25cb37a0b42 --- /dev/null +++ b/tests/components/select/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/sen5x/common-actions.yaml b/tests/components/sen5x/common-actions.yaml new file mode 100644 index 00000000000..a6294878016 --- /dev/null +++ b/tests/components/sen5x/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: SEN5x Clean the fan + on_press: + - sen5x.start_fan_autoclean: sen54 diff --git a/tests/components/sen5x/test-actions.esp8266-ard.yaml b/tests/components/sen5x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..6b6ff010670 --- /dev/null +++ b/tests/components/sen5x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sen5x: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index 7a6a9ffd4f3..bd6747ee07b 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -4,3 +4,6 @@ psram: sendspin: id: sendspin_hub_id task_stack_in_psram: true + manufacturer: Test Manufacturer + model: Test Model + firmware_version: 1.2.3 diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..87dfb0add6f 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,9 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + +wifi: + on_connect: + then: + - sendspin.media_source.enable_static_delay_adjustment: media_source_id + - sendspin.media_source.disable_static_delay_adjustment: media_source_id diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml new file mode 100644 index 00000000000..d332cb0dde0 --- /dev/null +++ b/tests/components/sendspin/common-switch.yaml @@ -0,0 +1,6 @@ +packages: + sendspin: !include common.yaml + +switch: + - platform: sendspin + name: "Sendspin Enabled" diff --git a/tests/components/sendspin/test-switch.esp32-idf.yaml b/tests/components/sendspin/test-switch.esp32-idf.yaml new file mode 100644 index 00000000000..d32c14c054a --- /dev/null +++ b/tests/components/sendspin/test-switch.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-switch.yaml diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index 1961c986853..08d1ec0c196 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -5,6 +5,17 @@ sensor: id: source_sensor lambda: return 42.0; update_interval: 1s + on_value: + - sensor.template.publish: + id: sensor_publish_target + state: 42.0 + - sensor.template.publish: + id: sensor_publish_target + state: !lambda "return x * 2.0;" + + - platform: template + name: "Publish Target" + id: sensor_publish_target # Streaming filters (window_size == send_every) - uses StreamingFilter base class - platform: copy diff --git a/tests/components/serial_proxy/test-tap.esp32-idf.yaml b/tests/components/serial_proxy/test-tap.esp32-idf.yaml new file mode 100644 index 00000000000..5522e53c476 --- /dev/null +++ b/tests/components/serial_proxy/test-tap.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +# Compile the tap code paths; no tap is attached, so this exercises the +# null-tap branches that a normal build never defines. +esphome: + platformio_options: + build_flags: + - "-DUSE_SERIAL_PROXY_TAP" + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + serial_proxy: !include common.yaml diff --git a/tests/components/servo/common.yaml b/tests/components/servo/common.yaml index 2cfa370c32a..797d05f41fb 100644 --- a/tests/components/servo/common.yaml +++ b/tests/components/servo/common.yaml @@ -4,6 +4,9 @@ esphome: - servo.write: id: test_servo level: -100.0% + - servo.write: + id: test_servo + level: !lambda return 0.5; - servo.detach: test_servo output: diff --git a/tests/components/snapshot/common.yaml b/tests/components/snapshot/common.yaml index 9ce2d33a875..e9c59437776 100644 --- a/tests/components/snapshot/common.yaml +++ b/tests/components/snapshot/common.yaml @@ -26,6 +26,11 @@ esphome: - snapshot.take: id: snapshot_rotated filename: !lambda 'return "rotated.bmp";' + - snapshot.take: + id: snapshot_display + filename: animation.gif + frames: 5 + frame_rate: 10fps interval: # A generated name has the time in it, so this one can repeat. diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp new file mode 100644 index 00000000000..d63101357a2 --- /dev/null +++ b/tests/components/snapshot/snapshot_test.cpp @@ -0,0 +1,125 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "esphome/components/snapshot/snapshot.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::snapshot::testing { +namespace fs = std::filesystem; + +/// A display of a fixed size that can be told to fail a capture. +class FakeSnapshot : public Snapshot { + public: + /// The capture with this number (counting from 1) fails. Zero means none do. + int fail_on{0}; + int captures{0}; + + protected: + int snapshot_width() override { return 8; } + int snapshot_height() override { return 4; } + bool capture_bgr(uint8_t *dest, size_t row_stride) override { + this->captures++; + if (this->captures == this->fail_on) + return false; + for (int y = 0; y != 4; y++) { + for (int x = 0; x != 8 * 3; x++) + dest[y * row_stride + x] = static_cast(x * 7 + y * 31 + this->captures); + } + return true; + } +}; + +class SnapshotAnimationTest : public ::testing::Test { + protected: + void SetUp() override { + this->dir_ = fs::temp_directory_path() / "esphome_snapshot_test"; + fs::remove_all(this->dir_); + fs::create_directories(this->dir_); + setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); + // The test main does not construct App as generated code does, and recording needs its scheduler. + static const bool app_constructed = (new (&App) Application(), true); + (void) app_constructed; + App.pre_setup("test_snapshot", 10, "", 0); + } + void TearDown() override { + unsetenv("ESPHOME_SNAPSHOT_DIR"); + fs::remove_all(this->dir_); + } + + /// Names of the files in the snapshot directory, in order. + std::vector files() const { + std::vector names; + for (const auto &entry : fs::directory_iterator(this->dir_)) + names.push_back(entry.path().filename().string()); + std::sort(names.begin(), names.end()); + return names; + } + + /// Let the scheduler run for long enough that a fast recording has finished. + static void run_scheduler() { + for (int i = 0; i != 50; i++) { + App.scheduler.call(millis()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + fs::path dir_; +}; + +TEST_F(SnapshotAnimationTest, FailedFirstCaptureLeavesNoFile) { + FakeSnapshot display; + display.fail_on = 1; + EXPECT_FALSE(display.take_animation("first.gif", 3, 50.0f)); + EXPECT_TRUE(this->files().empty()); +} + +TEST_F(SnapshotAnimationTest, FailedCaptureMidRecordingRemovesPartialFile) { + FakeSnapshot display; + display.fail_on = 2; + ASSERT_TRUE(display.take_animation("partial.gif", 3, 50.0f)); + // The first frame is already in the file. + EXPECT_EQ(this->files(), std::vector{"partial.gif"}); + + run_scheduler(); + EXPECT_TRUE(this->files().empty()); + // The failure ends the recording, so the display can record again. + display.fail_on = 0; + EXPECT_TRUE(display.take_animation("again.gif", 1, 50.0f)); + EXPECT_EQ(this->files(), std::vector{"again.gif"}); +} + +TEST_F(SnapshotAnimationTest, SecondRecordingIsRefusedUntilTheFirstEnds) { + FakeSnapshot display; + ASSERT_TRUE(display.take_animation("one.gif", 3, 50.0f)); + EXPECT_FALSE(display.take_animation("two.gif", 3, 50.0f)); + EXPECT_EQ(this->files(), std::vector{"one.gif"}); + + run_scheduler(); + std::ifstream in(this->dir_ / "one.gif", std::ios::binary); + std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + ASSERT_FALSE(data.empty()); + EXPECT_EQ(data.back(), ';'); // the GIF trailer: the recording finished + + EXPECT_TRUE(display.take_animation("two.gif", 1, 50.0f)); + EXPECT_EQ(this->files(), (std::vector{"one.gif", "two.gif"})); +} + +TEST_F(SnapshotAnimationTest, RejectsNoFramesAndNoFrameRate) { + FakeSnapshot display; + EXPECT_FALSE(display.take_animation("none.gif", 0, 10.0f)); + EXPECT_FALSE(display.take_animation("none.gif", 3, 0.0f)); + EXPECT_TRUE(this->files().empty()); +} + +} // namespace esphome::snapshot::testing +#endif diff --git a/tests/components/socket/__init__.py b/tests/components/socket/__init__.py new file mode 100644 index 00000000000..8a3da97e23d --- /dev/null +++ b/tests/components/socket/__init__.py @@ -0,0 +1,16 @@ +# This file's presence makes pytest treat this directory as a package named "socket", +# shadowing the stdlib module -- no test_*.py (Python-level) tests can live here. +# required for cpp unit testing. +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() + real_to_code = manifest.to_code + + async def to_code_testing(config): + await real_to_code(config) + cg.add_define("USE_NETWORK_IPV6", True) + + manifest.to_code = to_code_testing diff --git a/tests/components/socket/conftest.py b/tests/components/socket/conftest.py deleted file mode 100644 index 5d93cac232a..00000000000 --- a/tests/components/socket/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Configuration file for socket component tests.""" - -import pytest - -from esphome.core import CORE - - -@pytest.fixture(autouse=True) -def reset_core(): - """Reset CORE after each test.""" - yield - CORE.reset() diff --git a/tests/components/socket/test-ipv6.rp2040-ard.yaml b/tests/components/socket/test-ipv6.rp2040-ard.yaml new file mode 100644 index 00000000000..da1324b17ef --- /dev/null +++ b/tests/components/socket/test-ipv6.rp2040-ard.yaml @@ -0,0 +1,4 @@ +substitutions: + network_enable_ipv6: "true" + +<<: !include common.yaml diff --git a/tests/components/socket/test_sockaddr_host.cpp b/tests/components/socket/test_sockaddr_host.cpp new file mode 100644 index 00000000000..74ba5de8ce7 --- /dev/null +++ b/tests/components/socket/test_sockaddr_host.cpp @@ -0,0 +1,146 @@ +#include + +#include +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// Independent of USE_NETWORK_IPV6 +// ========================================================================= + +TEST(SetSockaddr, IPv4Basic) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "192.168.1.1", 6053); + ASSERT_EQ(len, sizeof(sockaddr_in)); + auto *in = reinterpret_cast(&addr); + EXPECT_EQ(in->sin_family, AF_INET); + EXPECT_EQ(ntohs(in->sin_port), 6053); + EXPECT_EQ(in->sin_addr.s_addr, htonl(0xC0A80101)); +} + +TEST(SetSockaddr, IPv4Broadcast) { + // 255.255.255.255 is INADDR_NONE's bit pattern but also a valid address. + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "255.255.255.255", 12345); + ASSERT_EQ(len, sizeof(sockaddr_in)); + auto *in = reinterpret_cast(&addr); + EXPECT_EQ(in->sin_addr.s_addr, ESPHOME_INADDR_NONE); +} + +TEST(SetSockaddr, IPv4MalformedYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "not-an-ip", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddr, IPv4BufferTooSmallYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(sockaddr_in) - 1, "192.168.1.1", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddr, StringOverloadMatchesCStringOverload) { + struct sockaddr_storage addr_c {}; + struct sockaddr_storage addr_s {}; + socklen_t len_c = set_sockaddr((struct sockaddr *) &addr_c, sizeof(addr_c), "10.0.0.5", 80); + socklen_t len_s = set_sockaddr((struct sockaddr *) &addr_s, sizeof(addr_s), std::string("10.0.0.5"), 80); + ASSERT_EQ(len_c, len_s); + EXPECT_EQ(std::memcmp(&addr_c, &addr_s, len_c), 0); +} + +TEST(FormatSockaddrTo, IPv4RoundTrip) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "203.0.113.7", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "203.0.113.7"); +} + +TEST(FormatSockaddrTo, UnknownFamilyYieldsEmptyString) { + struct sockaddr addr {}; + addr.sa_family = AF_UNSPEC; + char buf[SOCKADDR_STR_LEN] = {'x', '\0'}; + size_t written = format_sockaddr_to(&addr, sizeof(addr), buf); + EXPECT_EQ(written, 0u); + EXPECT_STREQ(buf, ""); +} + +// ========================================================================= +// IPv6 - this directory's __init__.py forces USE_NETWORK_IPV6 on +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(SetSockaddr, IPv6Basic) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::1", 443); + ASSERT_EQ(len, sizeof(sockaddr_in6)); + auto *in6 = reinterpret_cast(&addr); + EXPECT_EQ(in6->sin6_family, AF_INET6); + EXPECT_EQ(ntohs(in6->sin6_port), 443); + EXPECT_TRUE(IN6_IS_ADDR_LOOPBACK(&in6->sin6_addr)); +} + +TEST(SetSockaddr, IPv6MalformedYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "gg::not-ipv6", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddrAny, FillsIPv6AnyAddressWithPort) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr_any((struct sockaddr *) &addr, sizeof(addr), 9999); + ASSERT_EQ(len, sizeof(sockaddr_in6)); + auto *in6 = reinterpret_cast(&addr); + EXPECT_EQ(in6->sin6_family, AF_INET6); + EXPECT_EQ(ntohs(in6->sin6_port), 9999); + EXPECT_TRUE(IN6_IS_ADDR_UNSPECIFIED(&in6->sin6_addr)); +} + +TEST(FormatSockaddrTo, IPv6RoundTrip) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "2001:db8::1", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "2001:db8::1"); +} + +TEST(FormatSockaddrTo, V4MappedFormatsAsPlainIPv4) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::ffff:198.51.100.9", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "198.51.100.9"); +} + +TEST(SocketIp, CreatesIPv6Socket) { + auto sock = socket_ip(SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + struct sockaddr_storage addr {}; + socklen_t len = sizeof(addr); + ASSERT_EQ(sock->getsockname((struct sockaddr *) &addr, &len), 0); + EXPECT_EQ(addr.ss_family, AF_INET6); +} + +#endif // USE_NETWORK_IPV6 + +} // namespace esphome::socket::testing + +#endif // USE_HOST diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index 3b2212a0caa..7aef904a920 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -30,3 +30,8 @@ script: id: speaker_media_player_id media_file: speaker_test_audio announcement: true + - media_player.speaker.play_on_device_media_file: + id: speaker_media_player_id + media_file: speaker_test_audio + announcement: !lambda return id(speaker_my_number).state > 50; + enqueue: !lambda return id(speaker_my_number).state < 50; diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 96f459c53f3..e5c9f366504 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -25,6 +25,9 @@ esphome: - speaker.volume_set: id: speaker_id volume: 0.9 + - speaker.volume_set: + id: speaker_id + volume: !lambda return id(speaker_my_number).state / 100.0f; - if: condition: speaker.is_playing: diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml index 5a2fd16938d..a8d47dd2538 100644 --- a/tests/components/speaker_source/test.esp32-idf.yaml +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -4,4 +4,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml -<<: !include common.yaml + speaker_source: !include common.yaml diff --git a/tests/components/split_buffer/split_buffer_test.cpp b/tests/components/split_buffer/split_buffer_test.cpp new file mode 100644 index 00000000000..e9cdb12a3b7 --- /dev/null +++ b/tests/components/split_buffer/split_buffer_test.cpp @@ -0,0 +1,213 @@ +#include + +#include +#include +#include + +#include "esphome/components/split_buffer/split_buffer.h" + +namespace esphome::split_buffer::testing { + +static std::vector make_pattern(size_t length, uint8_t seed = 1) { + std::vector data(length); + for (size_t i = 0; i != length; i++) + data[i] = static_cast(seed + i); + return data; +} + +static std::vector read_all(const SplitBuffer &buffer) { + std::vector out(buffer.size()); + for (size_t i = 0; i != buffer.size(); i++) + out[i] = buffer[i]; + return out; +} + +TEST(SplitBufferInit, SingleBufferByDefault) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(100)); + EXPECT_TRUE(buffer.is_valid()); + EXPECT_EQ(buffer.size(), 100u); + EXPECT_EQ(buffer.get_buffer_count(), 1u); +} + +TEST(SplitBufferInit, MaxBufferSizeSplits) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(100, 32)); + EXPECT_EQ(buffer.size(), 100u); + EXPECT_EQ(buffer.get_buffer_count(), 4u); +} + +TEST(SplitBufferInit, ZeroLengthOrMaxFails) { + SplitBuffer buffer; + EXPECT_FALSE(buffer.init(0)); + EXPECT_FALSE(buffer.init(100, 0)); + EXPECT_FALSE(buffer.is_valid()); +} + +TEST(SplitBufferInit, StartsZeroed) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + EXPECT_EQ(read_all(buffer), std::vector(50, 0)); +} + +TEST(SplitBufferFill, FillsShortLastBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + buffer.fill(0xA5); + EXPECT_EQ(read_all(buffer), std::vector(50, 0xA5)); +} + +TEST(SplitBufferGetSpan, SingleBufferCoversRest) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(100)); + size_t length = 0; + EXPECT_EQ(buffer.get_span(0, length), &buffer[0]); + EXPECT_EQ(length, 100u); + EXPECT_EQ(buffer.get_span(99, length), &buffer[99]); + EXPECT_EQ(length, 1u); +} + +TEST(SplitBufferGetSpan, StopsAtSubBufferBoundary) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t length = 0; + EXPECT_EQ(buffer.get_span(0, length), &buffer[0]); + EXPECT_EQ(length, 16u); + EXPECT_EQ(buffer.get_span(10, length), &buffer[10]); + EXPECT_EQ(length, 6u); + EXPECT_EQ(buffer.get_span(15, length), &buffer[15]); + EXPECT_EQ(length, 1u); + EXPECT_EQ(buffer.get_span(16, length), &buffer[16]); + EXPECT_EQ(length, 16u); +} + +TEST(SplitBufferGetSpan, ShortLastBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t length = 0; + EXPECT_EQ(buffer.get_span(48, length), &buffer[48]); + EXPECT_EQ(length, 2u); + EXPECT_EQ(buffer.get_span(49, length), &buffer[49]); + EXPECT_EQ(length, 1u); +} + +TEST(SplitBufferGetSpan, OutOfRange) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t length = 99; + EXPECT_EQ(buffer.get_span(50, length), nullptr); + EXPECT_EQ(length, 0u); + length = 99; + EXPECT_EQ(buffer.get_span(1000, length), nullptr); + EXPECT_EQ(length, 0u); +} + +TEST(SplitBufferGetSpan, ConstBufferGivesConstSpan) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const SplitBuffer &ref = buffer; + size_t length = 0; + const uint8_t *span = ref.get_span(20, length); + EXPECT_EQ(span, &ref[20]); + EXPECT_EQ(length, 12u); +} + +TEST(SplitBufferInit, FailedInitLeavesEmptyState) { + SplitBuffer buffer; + // One-byte pieces need a pointer array too large to allocate, so init fails straight away + EXPECT_FALSE(buffer.init(SIZE_MAX / 16, 1)); + EXPECT_EQ(buffer.size(), 0u); + size_t length = 99; + EXPECT_EQ(buffer.get_span(0, length), nullptr); + EXPECT_EQ(length, 0u); +} + +TEST(SplitBufferGetSpan, UninitializedReturnsNull) { + SplitBuffer buffer; + size_t length = 99; + EXPECT_EQ(buffer.get_span(0, length), nullptr); + EXPECT_EQ(length, 0u); +} + +TEST(SplitBufferGetSpan, SpansCoverWholeBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t index = 0; + std::vector lengths; + while (index < buffer.size()) { + size_t length = 0; + ASSERT_NE(buffer.get_span(index, length), nullptr); + lengths.push_back(length); + index += length; + } + EXPECT_EQ(index, 50u); + EXPECT_EQ(lengths, (std::vector{16, 16, 16, 2})); +} + +TEST(SplitBufferWrite, WithinOneSubBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(5); + buffer.write(3, data.data(), data.size()); + auto expected = std::vector(50, 0); + std::copy(data.begin(), data.end(), expected.begin() + 3); + EXPECT_EQ(read_all(buffer), expected); +} + +TEST(SplitBufferWrite, AcrossSubBuffers) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(30); + buffer.write(10, data.data(), data.size()); + auto expected = std::vector(50, 0); + std::copy(data.begin(), data.end(), expected.begin() + 10); + EXPECT_EQ(read_all(buffer), expected); +} + +TEST(SplitBufferWrite, WholeBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(50); + buffer.write(0, data.data(), data.size()); + EXPECT_EQ(read_all(buffer), data); +} + +TEST(SplitBufferWrite, TruncatesPastEnd) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + // The source is sized to the full request so ASan catches any read beyond it. + const auto data = make_pattern(20); + buffer.write(40, data.data(), data.size()); + auto expected = std::vector(50, 0); + std::copy(data.begin(), data.begin() + 10, expected.begin() + 40); + EXPECT_EQ(read_all(buffer), expected); +} + +TEST(SplitBufferWrite, StartOutOfRangeIsIgnored) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(5); + buffer.write(50, data.data(), data.size()); + EXPECT_EQ(read_all(buffer), std::vector(50, 0)); +} + +TEST(SplitBufferWrite, ZeroLengthIsNoop) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + buffer.write(0, nullptr, 0); + EXPECT_EQ(read_all(buffer), std::vector(50, 0)); +} + +TEST(SplitBufferWrite, MatchesContiguousBuffer) { + // Each sub-buffer size, including ones that divide the total evenly, must give the same result. + const auto data = make_pattern(64, 7); + for (size_t max_size : {1u, 3u, 8u, 16u, 63u, 64u, 1000u}) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(64, max_size)); + buffer.write(0, data.data(), 20); + buffer.write(20, data.data() + 20, 44); + EXPECT_EQ(read_all(buffer), data) << "max_buffer_size=" << max_size; + } +} + +} // namespace esphome::split_buffer::testing diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index dbe109f5244..890cf9fd884 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -31,6 +31,18 @@ esphome: id: yard_sprinkler_ctrlr valve_number: 0 run_duration: 600s + # Templated values, including both keys of the two-key calls + - sprinkler.set_multiplier: + id: yard_sprinkler_ctrlr + multiplier: !lambda return 1.5f; + - sprinkler.queue_valve: + id: yard_sprinkler_ctrlr + valve_number: !lambda return 1; + run_duration: !lambda return 300; + - sprinkler.set_valve_run_duration: + id: yard_sprinkler_ctrlr + valve_number: !lambda return 1; + run_duration: !lambda return 120; switch: - platform: template diff --git a/tests/components/sps30/common-actions.yaml b/tests/components/sps30/common-actions.yaml new file mode 100644 index 00000000000..00b85ca7671 --- /dev/null +++ b/tests/components/sps30/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: SPS30 Actions + on_press: + - sps30.start_fan_autoclean: workshop_sps30 + - sps30.stop_measurement: workshop_sps30 + - sps30.start_measurement: workshop_sps30 diff --git a/tests/components/sps30/common.yaml b/tests/components/sps30/common.yaml index a83477b764d..47811f7544e 100644 --- a/tests/components/sps30/common.yaml +++ b/tests/components/sps30/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: sps30 + id: workshop_sps30 i2c_id: i2c_bus pm_1_0: name: Workshop PM <1µm Weight concentration diff --git a/tests/components/sps30/test-actions.esp8266-ard.yaml b/tests/components/sps30/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..1d4f15d8873 --- /dev/null +++ b/tests/components/sps30/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sps30: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/ssd1306_i2c/common-actions.yaml b/tests/components/ssd1306_i2c/common-actions.yaml new file mode 100644 index 00000000000..737fe7f26b9 --- /dev/null +++ b/tests/components/ssd1306_i2c/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Page check + on_press: + - if: + condition: + display.is_displaying_page: ssd1306_i2c_page1 + then: + - logger.log: On page 1 + - if: + condition: + display.is_displaying_page: + id: ssd1306_i2c_ssd1306_i2c_display + page_id: ssd1306_i2c_page2 + then: + - logger.log: On page 2 diff --git a/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml b/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..e18129c9199 --- /dev/null +++ b/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + reset_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ssd1306_i2c: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/stepper/common.yaml b/tests/components/stepper/common.yaml index fcf57596188..ba70fb7979b 100644 --- a/tests/components/stepper/common.yaml +++ b/tests/components/stepper/common.yaml @@ -25,3 +25,12 @@ switch: - stepper.report_position: id: test_stepper position: 0 + - stepper.set_speed: + id: test_stepper + speed: 300 steps/s + - stepper.set_acceleration: + id: test_stepper + acceleration: !lambda return 150.0f; + - stepper.set_deceleration: + id: test_stepper + deceleration: 250 steps/s^2 diff --git a/tests/components/sun/common-actions.yaml b/tests/components/sun/common-actions.yaml new file mode 100644 index 00000000000..460be698791 --- /dev/null +++ b/tests/components/sun/common-actions.yaml @@ -0,0 +1,21 @@ +button: + - platform: template + name: Sun position + on_press: + - if: + condition: + sun.is_above_horizon: + then: + - logger.log: Day + - if: + condition: + sun.is_below_horizon: + elevation: -6° + then: + - logger.log: Night + - if: + condition: + sun.is_above_horizon: + elevation: !lambda return 10.0; + then: + - logger.log: High sun diff --git a/tests/components/sun/test-actions.esp8266-ard.yaml b/tests/components/sun/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..82c874ac33c --- /dev/null +++ b/tests/components/sun/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + sun: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 3ea235cfb91..ca187b30f1e 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -29,3 +29,13 @@ esphome: - switch.control: id: the_switch state: !lambda return (1 > 2); + - if: + condition: + switch.is_on: the_switch + then: + - logger.log: "Switch is on" + - if: + condition: + switch.is_off: the_switch + then: + - logger.log: "Switch is off" diff --git a/tests/components/sx126x/common-actions.yaml b/tests/components/sx126x/common-actions.yaml new file mode 100644 index 00000000000..2612095cc60 --- /dev/null +++ b/tests/components/sx126x/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: SX126x Mode Actions + on_press: + - sx126x.set_mode_tx + - sx126x.set_mode_sleep: + cold: true + - sx126x.set_mode_sleep: + cold: !lambda return id(sx126x_my_number).state > 1; diff --git a/tests/components/sx126x/test-actions.esp8266-ard.yaml b/tests/components/sx126x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..1d412d58967 --- /dev/null +++ b/tests/components/sx126x/test-actions.esp8266-ard.yaml @@ -0,0 +1,13 @@ +substitutions: + clk_pin: GPIO0 + mosi_pin: GPIO15 + miso_pin: GPIO16 + cs_pin: GPIO1 + rst_pin: GPIO2 + busy_pin: GPIO4 + dio1_pin: GPIO3 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sx126x: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/syslog/test-rfc5424.esp32-idf.yaml b/tests/components/syslog/test-rfc5424.esp32-idf.yaml new file mode 100644 index 00000000000..3a49aeb57d7 --- /dev/null +++ b/tests/components/syslog/test-rfc5424.esp32-idf.yaml @@ -0,0 +1,8 @@ +<<: !include common.yaml + +syslog: + port: 514 + strip: true + level: info + facility: 16 + format: RFC5424 diff --git a/tests/components/systa_bus/__init__.py b/tests/components/systa_bus/__init__.py new file mode 100644 index 00000000000..95273b5ad58 --- /dev/null +++ b/tests/components/systa_bus/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Listener storage is sized by code generation; the gtests register one listener per bus. + cg.add_define("SYSTA_BUS_LISTENER_COUNT", 1) + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/systa_bus/common.h b/tests/components/systa_bus/common.h new file mode 100644 index 00000000000..9824059702e --- /dev/null +++ b/tests/components/systa_bus/common.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/components/systa_bus/systa_bus.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::systa_bus::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +} // namespace esphome::systa_bus::testing diff --git a/tests/components/systa_bus/common.yaml b/tests/components/systa_bus/common.yaml new file mode 100644 index 00000000000..ea97e2d840b --- /dev/null +++ b/tests/components/systa_bus/common.yaml @@ -0,0 +1,15 @@ +systa_bus: + +sensor: + - platform: systa_bus + model: systasolar_aqua + temperature_tsa: + name: TSA + temperature_tse: + name: TSE + temperature_twu: + name: TWU + temperature_tw2: + name: TW2 + pump_speed: + name: Pump Speed diff --git a/tests/components/systa_bus/systa_bus_test.cpp b/tests/components/systa_bus/systa_bus_test.cpp new file mode 100644 index 00000000000..3e6de9d2800 --- /dev/null +++ b/tests/components/systa_bus/systa_bus_test.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "common.h" + +namespace esphome::systa_bus::testing { + +namespace { + +class TestListener final : public SystaBusListener { + public: + void handle_message(std::span message) override { + this->messages.emplace_back(message.begin(), message.end()); + } + std::vector> messages; +}; + +void put_i16be(std::vector &frame, size_t offset, int16_t value) { + frame[offset] = static_cast(static_cast(value) >> 8); + frame[offset + 1] = static_cast(value); +} + +// A 25-byte Aqua sensor frame with a valid checksum +std::vector aqua_frame(int16_t tsa, int16_t tse, int16_t twu, int16_t tw2, uint8_t pump) { + std::vector frame(MAX_MESSAGE_SIZE, 0); + frame[0] = START_BYTE; + frame[1] = MESSAGE_TYPE_AQUA_SENSOR_DATA & 0xff; + put_i16be(frame, 4, tsa); + put_i16be(frame, 6, tse); + put_i16be(frame, 8, twu); + put_i16be(frame, 10, tw2); + frame[12] = pump; + uint8_t sum = 0; + for (size_t i = 0; i + 1 < frame.size(); i++) + sum += frame[i]; + frame.back() = static_cast(0 - sum); + return frame; +} + +class SystaBusTest : public ::testing::Test { + protected: + void SetUp() override { + this->bus_.set_uart_parent(&this->uart_); + this->bus_.register_listener(&this->listener_); + } + void feed_(const std::vector &bytes) { + this->uart_.push_rx(bytes); + this->bus_.loop(); + } + + MockUARTComponent uart_; + SystaBus bus_; + TestListener listener_; +}; + +const std::vector FRAME_A = aqua_frame(512, 301, -15, 700, 60); +const std::vector FRAME_B = aqua_frame(513, 302, -14, 701, 61); + +} // namespace + +TEST_F(SystaBusTest, DecodesAValidFrame) { + this->feed_(FRAME_A); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, IgnoresGarbageBeforeAFrame) { + std::vector bytes = {0x00, 0x16, 0x42, 0xfc}; // includes a stray start byte + bytes.insert(bytes.end(), FRAME_A.begin(), FRAME_A.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, SkipsAnUnknownMessageType) { + std::vector bytes = {START_BYTE, 0x1a, 0x01, 0x02, 0x03}; + bytes.insert(bytes.end(), FRAME_A.begin(), FRAME_A.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, RejectsACorruptedByte) { + auto bad = FRAME_A; + bad[6] ^= 0x10; + std::vector bytes = bad; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_B); +} + +// A frame that lost a byte swallows the start byte of the next frame; the parser must still find that frame. +TEST_F(SystaBusTest, RecoversTheFrameAfterADroppedByte) { + auto truncated = FRAME_A; + truncated.erase(truncated.begin() + 9); + std::vector bytes = truncated; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_B); +} + +TEST_F(SystaBusTest, DecodesFramesSplitAcrossLoops) { + std::vector first(FRAME_A.begin(), FRAME_A.begin() + 10); + std::vector second(FRAME_A.begin() + 10, FRAME_A.end()); + this->feed_(first); + EXPECT_TRUE(this->listener_.messages.empty()); + this->feed_(second); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, DecodesBackToBackFrames) { + std::vector bytes = FRAME_A; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 2u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); + EXPECT_EQ(this->listener_.messages[1], FRAME_B); +} + +} // namespace esphome::systa_bus::testing diff --git a/tests/components/systa_bus/test.esp32-idf.yaml b/tests/components/systa_bus/test.esp32-idf.yaml new file mode 100644 index 00000000000..0393d30348d --- /dev/null +++ b/tests/components/systa_bus/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + systa_bus: !include common.yaml diff --git a/tests/components/tas2780/common.yaml b/tests/components/tas2780/common.yaml new file mode 100644 index 00000000000..5d825f8a8d0 --- /dev/null +++ b/tests/components/tas2780/common.yaml @@ -0,0 +1,31 @@ +audio_dac: + - platform: tas2780 + id: tas2780_amp + address: 0x38 + i2c_id: i2c_bus + amp_level: 8 + power_mode: 2 + vol_range_min: 0.3 + vol_range_max: 1.0 + channel: mono + +esphome: + on_boot: + - tas2780.activate: + id: tas2780_amp + - tas2780.activate: + id: tas2780_amp + power_mode: 1 + - tas2780.activate: + id: tas2780_amp + power_mode: !lambda "return id(tas2780_amp).volume() > 0.5f ? 3 : 1;" + - tas2780.update_config: + id: tas2780_amp + amp_level: 10 + vol_range_min: 0.2 + vol_range_max: 0.9 + channel: left + - tas2780.deactivate: + id: tas2780_amp + - tas2780.reset: + id: tas2780_amp diff --git a/tests/components/tas2780/test.esp32-idf.yaml b/tests/components/tas2780/test.esp32-idf.yaml new file mode 100644 index 00000000000..34984dd9372 --- /dev/null +++ b/tests/components/tas2780/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + tas2780: !include common.yaml diff --git a/tests/components/tas2780/test.esp8266-ard.yaml b/tests/components/tas2780/test.esp8266-ard.yaml new file mode 100644 index 00000000000..39d8c2fda99 --- /dev/null +++ b/tests/components/tas2780/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tas2780: !include common.yaml diff --git a/tests/components/tas2780/test.rp2040-ard.yaml b/tests/components/tas2780/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fdc371097f9 --- /dev/null +++ b/tests/components/tas2780/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + tas2780: !include common.yaml diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 02aedaf167d..ccef9ded052 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -45,6 +45,10 @@ esphome: id: template_climate_custom_modes custom_fan_mode: "turbo" custom_preset: "eco_plus" + - climate.template.publish: + id: template_climate_custom_modes + custom_fan_mode: !lambda return std::string("turbo"); + custom_preset: !lambda return std::string("eco_plus"); # 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. @@ -257,8 +261,14 @@ switch: return false; turn_on_action: - logger.log: "turn_on_action" + - switch.template.publish: + id: test_switch + state: true turn_off_action: - logger.log: "turn_off_action" + - switch.template.publish: + id: test_switch + state: !lambda return false; button: - platform: template @@ -319,7 +329,7 @@ cover: logger.log: Cover is closed # Exercise cover.control / cover.template.publish action variants so they # get build coverage in CI (and so memory-impact analysis on PRs that - # touch ControlAction / CoverPublishAction sees real instances). + # touch these actions sees real instances). - platform: template name: "Template Cover Actions" id: template_cover_actions @@ -367,6 +377,10 @@ cover: - cover.control: id: template_cover_actions stop: true + - cover.open: template_cover_actions + - cover.close: template_cover_actions + - cover.stop: template_cover_actions + - cover.toggle: template_cover_actions # CONF_STATE alias for position - cover.control: id: template_cover_actions @@ -461,6 +475,7 @@ select: lock: - platform: template + id: template_lock name: "Template Lock" lambda: |- if (id(template_some_binary_sensor).state) { @@ -469,8 +484,14 @@ lock: return LOCK_STATE_UNLOCKED; lock_action: - logger.log: lock_action + - lock.template.publish: + id: template_lock + state: LOCKED unlock_action: - logger.log: unlock_action + - lock.template.publish: + id: template_lock + state: !lambda "return LOCK_STATE_UNLOCKED;" open_action: - logger.log: open_action @@ -490,10 +511,18 @@ valve: - valve.template.publish: id: template_valve state: CLOSED + - valve.template.publish: + id: template_valve + position: 50% + current_operation: IDLE + - valve.template.publish: + id: template_valve + position: !lambda "return 0.25f;" + current_operation: !lambda "return VALVE_OPERATION_CLOSING;" stop_action: - logger.log: stop_action # Exercise valve.control with various field combinations so the - # ControlAction codegen paths get build coverage. + # valve.control codegen paths get build coverage. - valve.control: id: template_valve stop: true @@ -522,11 +551,17 @@ text: update_interval: 1s lambda: | return std::string{"Hello!"}; + # Exercise a register_apply_condition inside a trigger with a std::string + # argument, so ApplyCondition is compiled. set_action: then: - - logger.log: - format: Template Text set to %s - args: ["x.c_str()"] + - if: + condition: + cover.is_open: template_cover_with_triggers + then: + - logger.log: + format: Template Text set to %s + args: ["x.c_str()"] alarm_control_panel: - platform: template diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index d06f3ce1317..a93fc0f5b6b 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -3,6 +3,10 @@ time: - platform: sntp # Required for datetime id: sntp_time + - platform: template + id: template_offset_time + lambda: |- + return id(sntp_time).timestamp_now() + 3600; wifi: # Required for sntp time ap: diff --git a/tests/components/text/common.yaml b/tests/components/text/common.yaml index 561d17143f4..3110b60fa69 100644 --- a/tests/components/text/common.yaml +++ b/tests/components/text/common.yaml @@ -28,3 +28,14 @@ text_sensor: - platform: text name: "Test Text State" source_id: test_text + +button: + - platform: template + name: Set Text + on_press: + - text.set: + id: test_text + value: Hello + - text.set: + id: test_text + value: !lambda return id(test_text).state + "!"; diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml index 97b0b8ad942..efbf4e1dc7a 100644 --- a/tests/components/text_sensor/common.yaml +++ b/tests/components/text_sensor/common.yaml @@ -32,6 +32,23 @@ text_sensor: filters: - map: - ON -> Active + # text_sensor.state with a constant and with a lambda, inside a trigger + # whose argument is a std::string. + on_value: + - if: + condition: + text_sensor.state: + id: test_substitute_single + state: Error + then: + - logger.log: substitute reports an error + - if: + condition: + text_sensor.state: + id: test_substitute_single + state: !lambda return x; + then: + - logger.log: substitute matches the mapped value - platform: template name: "Test Map Multiple" @@ -77,3 +94,14 @@ text_sensor: return {x.substr(0, 10) + "..."}; } return {x}; + + - platform: template + name: "Test Publish Action" + id: test_publish_action + on_value: + - text_sensor.template.publish: + id: test_map_single + state: "ON" + - text_sensor.template.publish: + id: test_map_single + state: !lambda 'return x + " again";' diff --git a/tests/components/tfluna/common.yaml b/tests/components/tfluna/common.yaml new file mode 100644 index 00000000000..57ecf892861 --- /dev/null +++ b/tests/components/tfluna/common.yaml @@ -0,0 +1,25 @@ +tfluna: + - i2c_id: i2c_bus + +sensor: + - platform: tfluna + distance: + name: "Distance" + signal_strength: + name: TF-Luna Signal Strength + temperature: + name: TF-Luna Temperature + timestamp: + name: TF-Luna Timestamp + +text_sensor: + - platform: tfluna + version: + name: "TF-Luna firmware version" + +button: + - platform: tfluna + factory_reset: + name: "factory reset" + restart: + name: "restart" diff --git a/tests/components/tfluna/test.esp32-idf.yaml b/tests/components/tfluna/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/tfluna/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/tfluna/test.esp8266-ard.yaml b/tests/components/tfluna/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/tfluna/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/tfluna/test.rp2040-ard.yaml b/tests/components/tfluna/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/tfluna/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/time/common.yaml b/tests/components/time/common.yaml index cd258c7aa6d..776a67cc7a6 100644 --- a/tests/components/time/common.yaml +++ b/tests/components/time/common.yaml @@ -12,3 +12,12 @@ time: - logger.log: "CronTrigger fired (every 10 seconds)" - platform: sntp id: sntp_time + +esphome: + on_boot: + - if: + condition: + time.has_time: + id: sntp_time + then: + - logger.log: "sntp_time has valid time" diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index 674e89dbe87..32db1999afe 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,6 +6,15 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + on_mount: + - logger.log: USB host mounted + - if: + condition: + tinyusb.is_mounted: + then: + - logger.log: USB host is mounted + on_unmount: + - logger.log: USB host unmounted # tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. usb_cdc_acm: diff --git a/tests/components/tinyusb/test.esp32-p4-idf.yaml b/tests/components/tinyusb/test.esp32-p4-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-p4-idf.yaml +++ b/tests/components/tinyusb/test.esp32-p4-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index 09b98ada401..67ea24f2c6c 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1,4 +1,10 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 # S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared # USB OTG peripheral; route the logger to UART0 so the fixture builds. diff --git a/tests/components/tinyusb/test.esp32-s3-idf.yaml b/tests/components/tinyusb/test.esp32-s3-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-s3-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 diff --git a/tests/components/tuya/test-no-network.bk72xx-ard.yaml b/tests/components/tuya/test-no-network.bk72xx-ard.yaml new file mode 100644 index 00000000000..64207e94e38 --- /dev/null +++ b/tests/components/tuya/test-no-network.bk72xx-ard.yaml @@ -0,0 +1,29 @@ +# Tuya without any network component (no wifi/ethernet/api), as used on +# serial-only or BLE-only Tuya MCU boards. Regression test for +# https://github.com/esphome/esphome/issues/18942 +substitutions: + status_pin: P6 + +packages: + uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml + +tuya: + status_pin: ${status_pin} + +binary_sensor: + - platform: tuya + id: tuya_presence + sensor_datapoint: 101 + +sensor: + - platform: tuya + id: tuya_light_intensity + sensor_datapoint: 103 + +number: + - platform: tuya + id: tuya_far_detection + number_datapoint: 109 + min_value: 0 + max_value: 600 + step: 1 diff --git a/tests/components/uart/test.esp32-c3-idf.yaml b/tests/components/uart/test.esp32-c3-idf.yaml index 2eae37e8245..d588203346d 100644 --- a/tests/components/uart/test.esp32-c3-idf.yaml +++ b/tests/components/uart/test.esp32-c3-idf.yaml @@ -24,5 +24,6 @@ uart: tx_pin: 18 rx_pin: 19 baud_rate: 115200 + clock_source: XTAL debug: debug_prefix: "[UART1] " diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index c8051880054..333576e1c83 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -28,6 +28,7 @@ uart: tx_pin: 21 rx_pin: 22 baud_rate: 115200 + clock_source: REF_TICK debug: debug_prefix: "[UART1] " - id: uart_debug_custom diff --git a/tests/components/uart_mux/common.yaml b/tests/components/uart_mux/common.yaml new file mode 100644 index 00000000000..3477f78b708 --- /dev/null +++ b/tests/components/uart_mux/common.yaml @@ -0,0 +1,44 @@ +tinyusb: + id: tinyusb_test + on_mount: + - uart_mux.select_bridge: mux_0 + on_unmount: + - uart_mux.select_local: mux_0 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 + +bridge: + - platform: cdc_acm_uart + id: bridge_0 + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + +uart_mux: + - id: mux_0 + bridge_id: bridge_0 + initial_route: local + +interval: + - interval: 60s + then: + - if: + condition: + uart_mux.is_local: mux_0 + then: + - lambda: |- + uint8_t byte; + if (id(mux_0).available() && id(mux_0).read_byte(&byte)) { + id(mux_0).write_byte(byte); + } diff --git a/tests/components/uart_mux/test.esp32-p4-idf.yaml b/tests/components/uart_mux/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s2-idf.yaml b/tests/components/uart_mux/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..5eaa3b38479 --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s2-idf.yaml @@ -0,0 +1,7 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s3-idf.yaml b/tests/components/uart_mux/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml diff --git a/tests/components/ufire_ise/common-actions.yaml b/tests/components/ufire_ise/common-actions.yaml new file mode 100644 index 00000000000..64a7fb287e4 --- /dev/null +++ b/tests/components/ufire_ise/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: uFire ISE Actions + on_press: + - ufire_ise.calibrate_probe_high: + id: ufire_ise_sensor + solution: !lambda return 10.0; + - ufire_ise.calibrate_probe_low: + id: ufire_ise_sensor + solution: !lambda return 4.0; diff --git a/tests/components/ufire_ise/test-actions.esp8266-ard.yaml b/tests/components/ufire_ise/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..9dd9c7d45a7 --- /dev/null +++ b/tests/components/ufire_ise/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ufire_ise: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/update/common.yaml b/tests/components/update/common.yaml index 40042945c8b..bcfdbd8c0bb 100644 --- a/tests/components/update/common.yaml +++ b/tests/components/update/common.yaml @@ -13,6 +13,8 @@ esphome: - update.check: - update.perform: force_update: true + - update.perform: + force_update: !lambda return false; wifi: ssid: MySSID diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5b23f9d685f..2e41fad1a1b 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -6,6 +6,7 @@ usb_uart: type: cdc_acm vid: 0x1234 pid: 0x5678 + claim_comm_interface: false channels: - id: channel_0_1 - id: uart_1 diff --git a/tests/components/valve/common.yaml b/tests/components/valve/common.yaml new file mode 100644 index 00000000000..bf01f77bc9e --- /dev/null +++ b/tests/components/valve/common.yaml @@ -0,0 +1,18 @@ +valve: + - platform: template + name: Test Valve + id: test_valve + optimistic: true + has_position: true + +button: + - platform: template + name: Valve Commands + on_press: + - valve.open: test_valve + - valve.close: test_valve + - valve.stop: test_valve + - valve.toggle: test_valve + - valve.control: + id: test_valve + position: 50% diff --git a/tests/components/valve/test.esp32-idf.yaml b/tests/components/valve/test.esp32-idf.yaml new file mode 100644 index 00000000000..3c2acee7efa --- /dev/null +++ b/tests/components/valve/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + valve: !include common.yaml diff --git a/tests/components/valve/test.esp8266-ard.yaml b/tests/components/valve/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3c2acee7efa --- /dev/null +++ b/tests/components/valve/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + valve: !include common.yaml diff --git a/tests/components/voice_assistant/common-actions.yaml b/tests/components/voice_assistant/common-actions.yaml new file mode 100644 index 00000000000..e529b62ca7d --- /dev/null +++ b/tests/components/voice_assistant/common-actions.yaml @@ -0,0 +1,14 @@ +button: + - platform: template + name: Voice assistant state + on_press: + - if: + condition: + voice_assistant.is_running: + then: + - logger.log: Running + - if: + condition: + voice_assistant.connected: + then: + - logger.log: Connected diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 812e7a2314c..998ba90dd99 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -2,6 +2,9 @@ esphome: on_boot: then: - voice_assistant.start + - voice_assistant.start: + wake_word: okay_nabu + silence_detection: false - voice_assistant.start_continuous - voice_assistant.stop diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 8604bea795c..5a5791899e2 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -2,6 +2,9 @@ esphome: on_boot: then: - voice_assistant.start + - voice_assistant.start: + wake_word: okay_nabu + silence_detection: false - voice_assistant.start_continuous - voice_assistant.stop diff --git a/tests/components/voice_assistant/test-actions.esp32-idf.yaml b/tests/components/voice_assistant/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..7cd9a5c3553 --- /dev/null +++ b/tests/components/voice_assistant/test-actions.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_din_pin: GPIO34 + i2s_din_pin2: GPIO35 + i2s_dout_pin: GPIO32 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + voice_assistant: !include common-idf.yaml + actions: !include common-actions.yaml diff --git a/tests/components/web_server_idf/test_sse_chunk.cpp b/tests/components/web_server_idf/test_sse_chunk.cpp new file mode 100644 index 00000000000..4e34f90d774 --- /dev/null +++ b/tests/components/web_server_idf/test_sse_chunk.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/web_server_idf/sse_chunk.h" + +namespace esphome::web_server_idf::testing { + +// The chunk as try_send_nodefer lays it out: header, fields, first "data: ", then the pieces +static std::string build_chunk(const char *message, size_t message_len, const char *event, uint32_t id, + uint32_t reconnect) { + char prefix[128]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), event, id, reconnect, message != nullptr); + if (message == nullptr && prefix_len == CHUNK_HDR_LEN) { + return ""; + } + std::string out(prefix, prefix_len); + for_each_chunk_piece( + message, message_len, + [](void *ctx, const char *piece, size_t len) { static_cast(ctx)->append(piece, len); }, &out); + write_chunk_header(prefix, out.size() - CHUNK_HDR_LEN - CHUNK_END_LEN); + out.replace(0, CHUNK_HDR_LEN, prefix, CHUNK_HDR_LEN); + return out; +} + +// The std::string builder this framing replaced, kept as the reference for the wire format +static std::string reference_chunk(const char *message, size_t message_len, const char *event, uint32_t id, + uint32_t reconnect) { + std::string buf = " \r\n"; + char num[32]; + if (reconnect) + buf.append(num, snprintf(num, sizeof(num), "retry: %u\r\n", reconnect)); + if (id) + buf.append(num, snprintf(num, sizeof(num), "id: %u\r\n", id)); + if (event && *event) + buf.append("event: ").append(event).append("\r\n"); + if (message) { + const char *line_start = message; + const char *msg_end = message + message_len; + const char *next_n = static_cast(memchr(message, '\n', message_len)); + const char *next_r = static_cast(memchr(message, '\r', message_len)); + if (next_n == nullptr && next_r == nullptr) { + buf.append("data: ").append(message, message_len).append("\r\n\r\n"); + } else { + while (line_start <= msg_end) { + const char *line_end; + const char *next_line; + if (next_n == nullptr && next_r == nullptr) { + buf.append("data: ").append(line_start, msg_end - line_start).append("\r\n"); + break; + } + if (next_n != nullptr && next_r != nullptr) { + if (next_r + 1 == next_n) { + line_end = next_r; + next_line = next_n + 1; + } else { + line_end = (next_r < next_n) ? next_r : next_n; + next_line = line_end + 1; + } + } else if (next_n != nullptr) { + line_end = next_n; + next_line = next_n + 1; + } else { + line_end = next_r; + next_line = next_r + 1; + } + buf.append("data: ").append(line_start, line_end - line_start).append("\r\n"); + line_start = next_line; + if (line_start >= msg_end) + break; + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); + } + buf.append("\r\n"); + } + } + if (buf.size() == 10) + return ""; + buf.append("\r\n"); + char len[9]; + snprintf(len, sizeof(len), "%08x", static_cast(buf.size() - 2 - 10)); + buf.replace(0, 8, len, 8); + return buf; +} + +static void expect_same(const std::string &message, const char *event, uint32_t id, uint32_t reconnect) { + const std::string got = build_chunk(message.data(), message.size(), event, id, reconnect); + const std::string want = reference_chunk(message.data(), message.size(), event, id, reconnect); + EXPECT_EQ(got, want) << "message=[" << message << "] event=" << (event ? event : "null") << " id=" << id + << " retry=" << reconnect; +} + +TEST(SseChunk, NullMessageHasNoDataLineAndNoBlankLine) { + EXPECT_EQ(build_chunk(nullptr, 0, "ping", 7, 30000), reference_chunk(nullptr, 0, "ping", 7, 30000)); + EXPECT_EQ(build_chunk(nullptr, 0, "ping", 7, 30000), "00000022\r\nretry: 30000\r\nid: 7\r\nevent: ping\r\n\r\n"); + EXPECT_EQ(build_chunk(nullptr, 0, nullptr, 0, 0), ""); +} + +TEST(SseChunk, SingleLine) { + EXPECT_EQ(build_chunk("{}", 2, "state", 0, 0), "0000001a\r\nevent: state\r\ndata: {}\r\n\r\n\r\n"); + expect_same("", "state", 0, 0); + expect_same(R"({"id":"light-x"})", "state_detail_all", 0, 0); +} + +TEST(SseChunk, LineBreaks) { + for (const char *m : {"a\n", "a\r", "a\r\n", "\n", "\r\n", "\r", "a\n\nb", "a\r\rb", "a\n\rb", "a\r\n\r\nb", "\n\n", + "x\r\n", "\r\nx", "one\ntwo\nthree", "tail\r\n\r\n"}) { + expect_same(m, "log", 1234, 0); + } + EXPECT_EQ(build_chunk("a\r\nb", 4, nullptr, 0, 0), "00000014\r\ndata: a\r\ndata: b\r\n\r\n\r\n"); + EXPECT_EQ(build_chunk("a\n\rb", 4, nullptr, 0, 0), "0000001c\r\ndata: a\r\ndata: \r\ndata: b\r\n\r\n\r\n"); +} + +TEST(SseChunk, MatchesReferenceOnRandomMessages) { + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + const char *events[] = {nullptr, "", "ping", "state", "log", "state_detail_all", "sorting_group"}; + const char alphabet[] = "ab{}\":,\n\r "; + for (int i = 0; i < 20000; i++) { + std::string m; + const size_t len = rng() % 120; + for (size_t k = 0; k < len; k++) + m += alphabet[rng() % (sizeof(alphabet) - 1)]; + expect_same(m, events[rng() % 7], (rng() % 3 == 0) ? 0 : rng(), (rng() % 4 == 0) ? 30000 : 0); + } +} + +} // namespace esphome::web_server_idf::testing diff --git a/tests/components/xiaomi_body_scale/common-ln.yaml b/tests/components/xiaomi_body_scale/common-ln.yaml new file mode 100644 index 00000000000..a6492ed0dc5 --- /dev/null +++ b/tests/components/xiaomi_body_scale/common-ln.yaml @@ -0,0 +1,16 @@ +sensor: + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h new file mode 100644 index 00000000000..67f1eaae91a --- /dev/null +++ b/tests/components/xiaomi_body_scale/common.h @@ -0,0 +1,78 @@ +#pragma once + +#include "esphome/components/xiaomi_body_scale/xiaomi_body_scale.h" +#include "esphome/core/application.h" + +#include +#include + +namespace esphome::xiaomi_body_scale::testing { + +using Frame = std::array; + +// Real captures and keys from the xiaomi-ble test suite (Bluetooth-Devices/xiaomi-ble) +static constexpr uint64_t SCALE_A = 0x8CD0B2F6BEEFULL; +static constexpr const char *KEY_A = "0728974d657a4b60964c1b1677f35f7c"; +// Weight, heart rate and the 50 kHz impedance +static constexpr Frame PACKET_1 = {0x48, 0x59, 0xd5, 0x3b, 0x0a, 0xbc, 0x07, 0x8f, 0xf2, 0x34, 0x8c, 0x84, + 0x41, 0x38, 0xe9, 0x30, 0x22, 0x00, 0x00, 0x00, 0x9e, 0x53, 0x85, 0x99}; +// Only the 250 kHz impedance, the end of a bare feet measurement +static constexpr Frame PACKET_2 = {0x48, 0x59, 0xd5, 0x3b, 0x0b, 0xd6, 0xef, 0x0b, 0x25, 0xdb, 0x72, 0x78, + 0x5e, 0x7e, 0x2f, 0x46, 0xd6, 0x00, 0x00, 0x00, 0xd8, 0x64, 0x2d, 0xf6}; +// PACKET_1 re-encrypted with KEY_A and frame count 0xFF +static constexpr Frame PACKET_1_COUNT_FF = {0x48, 0x59, 0xd5, 0x3b, 0xff, 0x6c, 0x2f, 0xd8, 0xdb, 0xaa, 0x70, 0xc7, + 0xbb, 0x1f, 0x0a, 0x08, 0x9c, 0x00, 0x00, 0x00, 0x31, 0xce, 0xf1, 0x95}; + +static constexpr uint64_t SCALE_B = 0x04AE4767C67CULL; +static constexpr const char *KEY_B = "02d2900363ef629c736a4549677acbee"; +// Weight without impedance, the end of a measurement with socks +static constexpr Frame SOCKS = {0x48, 0x59, 0xd5, 0x3b, 0x71, 0x53, 0x04, 0x38, 0xb5, 0x89, 0x4b, 0x24, + 0x2c, 0x20, 0x99, 0x08, 0xda, 0x00, 0x00, 0x00, 0x47, 0x9e, 0xcd, 0xa3}; +// All metrics zero, stepped off the scale +static constexpr Frame STEP_OFF = {0x48, 0x59, 0xd5, 0x3b, 0x72, 0x03, 0x6c, 0x67, 0x94, 0x35, 0x5a, 0x19, + 0xdb, 0xc8, 0x64, 0xbf, 0xb3, 0x00, 0x00, 0x00, 0xe4, 0x15, 0x1d, 0xc8}; + +// S200: weight only, 62.25 kg for profile 1 +static constexpr uint64_t SCALE_S200 = 0xD07B6F27D729ULL; +static constexpr const char *KEY_S200 = "653b1b10e1cb35e4ac5e60fa45f3bf29"; +static constexpr Frame S200_WEIGHT = {0x48, 0x59, 0x04, 0x4c, 0x01, 0x9a, 0x80, 0xa2, 0x75, 0x93, 0x90, 0x10, + 0xf0, 0xab, 0xc4, 0xfa, 0xdc, 0x06, 0x00, 0x00, 0x3d, 0x29, 0xc0, 0x44}; + +inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &frame) { + // Service data AD structure for UUID 0xFE95 + std::vector adv = {static_cast(frame.size() + 3), 0x16, 0x95, 0xFE}; + adv.insert(adv.end(), frame.begin(), frame.end()); + uint8_t mac[6]; + for (size_t i = 0; i < 6; i++) + mac[i] = static_cast(address >> (i * 8)); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); + return device; +} + +struct Harness { + Harness(uint64_t address, const char *key) : scale(address, key) { + // The test main does not construct App as generated code does; the stabilized reset needs its scheduler + static const bool app_constructed = (new (&App) Application(), true); + (void) app_constructed; + App.pre_setup("test_scale", 10, "", 0); + this->scale.set_weight(&this->weight); + this->scale.set_impedance_low(&this->impedance_low); + this->scale.set_impedance_high(&this->impedance_high); + this->scale.set_heart_rate(&this->heart_rate); + this->scale.set_profile_id(&this->profile_id); + this->scale.set_stabilized(&this->stabilized); + } + + ~Harness() { + // Drop the pending stabilized reset (id 0) while the scale is alive; App outlives each test + App.scheduler.cancel_timeout(&this->scale, 0u); + App.scheduler.call(millis()); + } + + XiaomiBodyScale scale; + sensor::Sensor weight, impedance_low, impedance_high, heart_rate, profile_id; + binary_sensor::BinarySensor stabilized; +}; + +} // namespace esphome::xiaomi_body_scale::testing diff --git a/tests/components/xiaomi_body_scale/common.yaml b/tests/components/xiaomi_body_scale/common.yaml new file mode 100644 index 00000000000..6eb2bf76dd9 --- /dev/null +++ b/tests/components/xiaomi_body_scale/common.yaml @@ -0,0 +1,27 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_body_scale + ble_hub_id: ble_tracker_hub + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:00" + bindkey: "ffeeddccbbaa99887766554433221100" + weight: + name: "Second Scale Weight" diff --git a/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp b/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp new file mode 100644 index 00000000000..36a93710f9a --- /dev/null +++ b/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp @@ -0,0 +1,112 @@ +#include "../common.h" + +#include + +#include +#include + +namespace esphome::xiaomi_body_scale::testing { + +TEST(XiaomiBodyScale, DecodesWeightHeartRateAndLowImpedance) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FLOAT_EQ(h.weight.state, 69.9f); + EXPECT_FLOAT_EQ(h.impedance_low.state, 543.2f); + EXPECT_FLOAT_EQ(h.heart_rate.state, 92.0f); + EXPECT_FLOAT_EQ(h.profile_id.state, 1.0f); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_FALSE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, HighImpedancePacketCompletesTheMeasurement) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_2))); + EXPECT_FLOAT_EQ(h.impedance_high.state, 497.6f); + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.weight.has_state()); + EXPECT_TRUE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, StabilizedClearsAfterOneSecond) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_2))); + ASSERT_TRUE(h.stabilized.state); + const uint32_t start = millis(); + while (h.stabilized.state && millis() - start < 2000) { + App.scheduler.call(millis()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_FALSE(h.stabilized.state); + EXPECT_GE(millis() - start, 900u); +} + +TEST(XiaomiBodyScale, WeightWithoutImpedanceCompletesTheMeasurement) { + Harness h(SCALE_B, KEY_B); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, SOCKS))); + EXPECT_FLOAT_EQ(h.weight.state, 74.7f); + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_TRUE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, SteppingOffClearsStabilized) { + Harness h(SCALE_B, KEY_B); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, SOCKS))); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, STEP_OFF))); + EXPECT_FALSE(h.stabilized.state); + EXPECT_FLOAT_EQ(h.weight.state, 74.7f); // a zero weight is not published +} + +TEST(XiaomiBodyScale, DecodesS200Weight) { + Harness h(SCALE_S200, KEY_S200); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_S200, S200_WEIGHT))); + EXPECT_FLOAT_EQ(h.weight.state, 62.25f); + EXPECT_FLOAT_EQ(h.profile_id.state, 1.0f); + // The S200 has no impedance or heart rate, and does not drive stabilized + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_FALSE(h.heart_rate.has_state()); + EXPECT_FALSE(h.stabilized.has_state()); +} + +TEST(XiaomiBodyScale, IgnoresOtherAddresses) { + Harness h(SCALE_A, KEY_A); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_B, PACKET_1))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, RejectsAWrongBindkey) { + Harness h(SCALE_A, KEY_B); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, RejectsAPlaintextFrame) { + Harness h(SCALE_A, KEY_A); + Frame plain = PACKET_1; + plain[0] &= ~0x08; + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, plain))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, IgnoresARepeatedFrame) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); +} + +TEST(XiaomiBodyScale, AcceptsFrameCountFFAsTheFirstFrame) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1_COUNT_FF))); + EXPECT_FLOAT_EQ(h.weight.state, 69.9f); +} + +TEST(XiaomiBodyScale, AFailedFrameDoesNotBlockTheRealOne) { + Harness h(SCALE_A, KEY_A); + Frame forged = PACKET_1; + forged[23] ^= 0xFF; // corrupt the tag, same frame count + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, forged))); + EXPECT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); +} + +} // namespace esphome::xiaomi_body_scale::testing diff --git a/tests/components/xiaomi_body_scale/test.esp32-idf.yaml b/tests/components/xiaomi_body_scale/test.esp32-idf.yaml new file mode 100644 index 00000000000..4ea8a5c3792 --- /dev/null +++ b/tests/components/xiaomi_body_scale/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_body_scale: !include common.yaml diff --git a/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml b/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml new file mode 100644 index 00000000000..9a95e14750b --- /dev/null +++ b/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_body_scale: !include common-ln.yaml diff --git a/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml b/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml new file mode 100644 index 00000000000..6f677a951f5 --- /dev/null +++ b/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml @@ -0,0 +1,28 @@ +# Config-only: the CI bk72xx board cannot compile the BLE 5.x tracker +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_body_scale + ble_hub_id: ble_tracker_hub + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:00" + bindkey: "ffeeddccbbaa99887766554433221100" + weight: + name: "Second Scale Weight" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index ac25fb8faf7..706da1f10c1 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -3,13 +3,19 @@ packages: binary_sensor: - platform: template - name: "Garage Door Open 10" + name: "Occupancy Sensor" report: "default" - use_device_type: false + device_class: occupancy + cluster: device_class + endpoint: 3 - platform: template name: "Garage Door Open 12" report: "force" endpoint: 1 + - platform: template + name: "Garage Door Open 13" + endpoint: 2 + use_device_type: false sensor: - platform: template @@ -19,6 +25,25 @@ sensor: unit_of_measurement: "°C" endpoint: 1 use_device_type: true + cluster: device_class + - platform: template + name: "Light Sensor" + lambda: return 100.0; + device_class: illuminance + unit_of_measurement: "lx" + endpoint: 2 + use_device_type: true + cluster: device_class + - platform: template + name: "pressure_sensor" + unit_of_measurement: Pa + device_class: pressure + cluster: device_class + - platform: template + name: "flow_sensor" + unit_of_measurement: L/h + device_class: volume_flow_rate + cluster: device_class zigbee: model: zigbee_test diff --git a/tests/components/zigbee/validate.esp32-c6-idf.yaml b/tests/components/zigbee/validate.esp32-c6-idf.yaml new file mode 100644 index 00000000000..873b2286826 --- /dev/null +++ b/tests/components/zigbee/validate.esp32-c6-idf.yaml @@ -0,0 +1,36 @@ +packages: + zigbee: !include common_esp32.yaml + +sensor: + - platform: template + name: "humidity_sensor" + unit_of_measurement: "%" + device_class: humidity + cluster: device_class + endpoint: 2 + use_device_type: false + - platform: template + name: "pm25_sensor" + unit_of_measurement: 'µg/m³' + device_class: pm25 + cluster: device_class + - platform: template + name: "co2_sensor" + unit_of_measurement: ppm + device_class: carbon_dioxide + cluster: device_class + - platform: template + name: "pressure_sensor2" + unit_of_measurement: hPa + device_class: pressure + cluster: device_class + - platform: template + name: "flow_sensor2" + unit_of_measurement: m³/h + device_class: volume_flow_rate + cluster: device_class + - platform: template + name: "atmospheric_pressure_sensor" + unit_of_measurement: hPa + device_class: atmospheric_pressure + cluster: device_class diff --git a/tests/integration/README.md b/tests/integration/README.md index 790d9a3a11f..bee20409e80 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -21,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t - The fixture file must exist or the test will fail with a clear error message - The fixture automatically injects a dynamic port number into the API configuration +Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead +of the test-named file and compile it in a shared, hash-keyed build directory, so +the whole group pays one full compile and each test only a relink. The marker +argument must be a single-line string literal (CI test selection maps fixtures to +test files by scanning for it), and marked tests must hand the `yaml_config` +content to `run_compiled` unmodified. + ### Key Fixtures - `run_compiled` - Combines write, compile, and run operations into a single context manager diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 15c58608799..ce2e7f5bbc0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,17 +4,21 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Generator -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress import fcntl +from functools import cache +import hashlib import logging import os from pathlib import Path import platform +import re +import shutil import signal import socket -import subprocess import sys import tempfile +import time from typing import TextIO from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic @@ -23,8 +27,14 @@ import pytest_asyncio import esphome.config from esphome.core import CORE -from esphome.helpers import get_usable_cpu_count -from esphome.platformio.toolchain import get_idedata +from esphome.helpers import ( + get_usable_cpu_count, + read_file, + rmtree, + write_file, + write_file_if_changed, +) +from esphome.host.toolchain import get_elf_path from .const import ( API_CONNECTION_TIMEOUT, @@ -56,16 +66,24 @@ import pty # not available on Windows pytest.register_assert_rewrite("tests.integration.entity_utils") -def _get_platformio_env(cache_dir: Path) -> dict[str, str]: - """Get environment variables for PlatformIO with shared cache.""" +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "shared_yaml(name): load fixtures/.yaml and compile it in a shared, " + "hash-keyed incremental build directory", + ) + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + +# CI caches parts of this path; keep in sync with ci.yml integration-tests. +INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests" + + +def _get_build_env() -> dict[str, str]: + """Environment for an ``esphome compile`` subprocess.""" env = os.environ.copy() - env["PLATFORMIO_CORE_DIR"] = str(cache_dir) - env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - # libdeps is keyed only by env name (the device name), and fixtures share - # names; two xdist workers first-compiling the same name race pio pkg - # install in the same directory. Keep libdeps per worker. - worker = os.environ.get("PYTEST_XDIST_WORKER", "master") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Cap each compile's -j so several xdist workers do not each spawn a @@ -78,58 +96,47 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: ) # Compile with THIS tree's esphome sources, not wherever the venv's editable # install points (which may be a different git worktree or checkout). - repo_root = str(Path(__file__).resolve().parent.parent.parent) + repo_root = str(REPO_ROOT) existing = env.get("PYTHONPATH") env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root return env +# Registry libraries (noise-c, lvgl, ...) download into /pio_components, +# and every integration test gets its own data dir. Share one download cache +# per xdist worker instead: the converter has no cross-process lock, so +# workers must never write the same dir, while a worker's later tests (and +# later runs) reuse what it fetched. +LIBRARY_CACHE_ROOT = INTEGRATION_TESTS_ROOT / "pio_components" + + @pytest.fixture(scope="session") -def shared_platformio_cache() -> Generator[Path]: - """Initialize a shared PlatformIO cache for all integration tests.""" - # Use a dedicated directory for integration tests to avoid conflicts. - # CI caches parts of this path; keep in sync with ci.yml integration-tests. - test_cache_dir = Path.home() / ".esphome-integration-tests" - cache_dir = test_cache_dir / "platformio" +def shared_library_cache() -> Path: + """This worker's shared registry-library download cache.""" + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + # Keyed by checkout too: two sessions on one machine share worker ids + cache_dir = LIBRARY_CACHE_ROOT / _REPO_KEY / worker + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir - # Use a lock file in the home directory to ensure only one process initializes the cache - # This is needed when running with pytest-xdist - # The lock file must be in a directory that already exists to avoid race conditions - lock_file = Path.home() / ".esphome-integration-tests-init.lock" - # Always acquire the lock to ensure cache is ready before proceeding - with lock_file.open("w") as lock_fd: - fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) - - # Check if the native platform is installed (the actual indicator of a populated cache) - native_platform = cache_dir / "platforms" / "native" - if not native_platform.exists(): - # Create the test cache directory if it doesn't exist - test_cache_dir.mkdir(exist_ok=True) - - with tempfile.TemporaryDirectory() as tmpdir: - # Use the cache_init fixture for initialization - init_dir = Path(tmpdir) - fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml" - config_path = init_dir / "cache_init.yaml" - config_path.write_text(fixture_path.read_text()) - - # Run compilation to populate the cache - # We must succeed here to avoid race conditions where multiple - # tests try to populate the same cache directory simultaneously - env = _get_platformio_env(cache_dir) - - subprocess.run( - [sys.executable, "-m", "esphome", "compile", str(config_path)], - check=True, - cwd=init_dir, - env=env, - close_fds=False, - ) - - # Lock is held until here, ensuring cache is fully populated before any test proceeds - - yield cache_dir +def _link_library_cache(config_dir: Path, cache_dir: Path) -> None: + """Point a config dir's library download cache at the shared one + (blocking, run in executor).""" + data_dir = config_dir / ".esphome" + data_dir.mkdir(exist_ok=True) + link = data_dir / "pio_components" + if link.is_symlink(): + if link.resolve() == cache_dir.resolve(): + return + # A shared build dir was linked by another worker; writing through + # it would put two workers in one cache + link.unlink() + elif link.exists(): + # A real dir from a run predating the shared cache; nothing in it is + # worth more than a re-download + rmtree(link) + link.symlink_to(cache_dir, target_is_directory=True) @pytest.fixture(scope="module", autouse=True) @@ -162,13 +169,6 @@ def integration_test_dir() -> Generator[Path]: yield Path(tmpdir) -@pytest.fixture -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Host preferences persist per device name; give the test its own so a - provisioned key never leaks into another run.""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) - - @pytest.fixture def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]: """Reserve an unused TCP port by holding the socket open.""" @@ -188,21 +188,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int: return reserved_tcp_port[0] +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Give every test its own host prefs dir; prefs are keyed only by device + name, which tests sharing a fixture also share.""" + prefdir = tmp_path / "prefs" + monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir)) + return prefdir + + @pytest_asyncio.fixture async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str: """Load YAML configuration based on test name.""" - # Get the test function name - test_name: str = request.node.name - # Extract the base test name (remove test_ prefix and any parametrization) - base_name = test_name.replace("test_", "").partition("[")[0] + shared_name = _shared_yaml_name(request) + # Base test name: test_ prefix and any parametrization stripped + base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0] # Load the fixture file - fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml" + fixture_path = FIXTURES_DIR / f"{base_name}.yaml" if not fixture_path.exists(): raise FileNotFoundError(f"Fixture file not found: {fixture_path}") loop = asyncio.get_running_loop() - content = await loop.run_in_executor(None, fixture_path.read_text) + content = await loop.run_in_executor(None, read_file, fixture_path) # Replace the port in the config if it contains api section if "api:" in content: @@ -210,27 +218,28 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s content = content.replace("api:", f"api:\n port: {unused_tcp_port}") # Add debug build flags for integration tests to enable assertions - if "esphome:" in content and "platformio_options:" not in content: - # Add platformio_options with debug flags after esphome: + if "esphome:" in content and "build_flags:" not in content: + # Add build_flags with debug flags after esphome: content = content.replace( "esphome:", "esphome:\n" " # Enable assertions for integration tests\n" - " platformio_options:\n" - " build_flags:\n" - ' - "-DDEBUG" # Enable assert() statements\n' - ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' - ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' - ' - "-g" # Add debug symbols', + " build_flags:\n" + ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' + ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' + ' - "-g" # Add debug symbols', ) # Replace external component path placeholder if present if "EXTERNAL_COMPONENT_PATH" in content: - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) + external_components_path = str(FIXTURES_DIR / "external_components") content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) + if shared_name is not None: + # _compile verifies the marked test compiles this content unmodified + request.node._shared_yaml_content = content + return content @@ -240,91 +249,320 @@ async def write_yaml_config( ) -> AsyncGenerator[ConfigWriter]: """Write YAML configuration to a file.""" # Get the test name for default filename - test_name = request.node.name - base_name = test_name.replace("test_", "").split("[")[0] + base_name = request.node.name.replace("test_", "").partition("[")[0] async def _write_config(content: str, filename: str | None = None) -> Path: if filename is None: filename = f"{base_name}.yaml" config_path = integration_test_dir / filename loop = asyncio.get_running_loop() - await loop.run_in_executor(None, config_path.write_text, content) + await loop.run_in_executor(None, write_file, config_path, content) return config_path yield _write_config +# Deliberately not CI-cached; stale dirs for a fixture are pruned when its +# content hash changes. +SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds" + +# In the dir name (not just the hash) so pruning stays inside this checkout +_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8] + +# Give a contended shared build lock time for a full cold compile ahead of us +_SHARED_LOCK_TIMEOUT_S = 900 +_SHARED_LOCK_POLL_S = 0.1 +_SHARED_LOCK_REPORT_S = 30 + +# Reclaims dirs orphaned by fixture renames or deleted checkouts +_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600 + +# ELF path per shared build dir; constant once compiled, so resolve it only once +_shared_elf_paths: dict[Path, Path] = {} + +# Dirs this process already swept; pruning is session-scoped work +_pruned_dirs: set[Path] = set() + + +def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None: + """Name passed to the shared_yaml marker, or None when unmarked.""" + marker = request.node.get_closest_marker("shared_yaml") + if marker is None: + return None + # Exactly one \w+ positional arg: the name doubles as a build dir + # component, and CI test selection (script/helpers.py) parses the same shape + if ( + len(marker.args) != 1 + or marker.kwargs + or not re.fullmatch(r"\w+", str(marker.args[0])) + ): + raise ValueError( + "shared_yaml marker requires exactly one \\w+ fixture name literal" + ) + return marker.args[0] + + +def _shared_build_prefix(name: str) -> str: + return f"{name}-{_REPO_KEY}-" + + +@cache +def _shared_build_dir(name: str) -> Path: + """Dir keyed by checkout and fixture source, before per-test injections.""" + key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16] + return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key) + + +def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None: + """ELF path recorded by the last completed compile, or None.""" + try: + text = stamp.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + except OSError as err: + print(f"Cannot read {stamp}: {err}") + return None + if not text: + print(f"Ignoring empty stamp {stamp}") + return None + built = Path(text) + # Never trust a stamp pointing outside its own build dir as an unlink target + if shared_dir.resolve() in built.resolve().parents: + return built + print(f"Ignoring stamp {stamp} pointing outside {shared_dir}") + return None + + +def _unused_since(stale: Path, cutoff: float) -> bool: + """Whether a build dir looks untouched since cutoff; unknown counts as used.""" + # Newest of the .built stamp (rewritten by every completed compile) and the + # dir itself (freshened by a worker claiming the dir before locking) + newest: float | None = None + for probe in (stale / ".built", stale): + try: + mtime = probe.stat().st_mtime + except FileNotFoundError: + continue + except NotADirectoryError: + return True # a stray file where a dir should be; reclaimable + except OSError as err: + print(f"Cannot age-probe {stale}: {err}") + return False # unknown never authorizes deletion + newest = mtime if newest is None else max(newest, mtime) + return newest is not None and newest < cutoff + + +def _prune_stale_builds(name: str, keep: Path) -> None: + """Remove outdated build dirs (blocking, run in executor): this checkout's + other dirs for the fixture, plus anything untouched for 30 days. Tolerates + other workers pruning the same dirs concurrently.""" + cutoff = time.time() - _STALE_BUILD_MAX_AGE_S + prefix = _shared_build_prefix(name) + for stale in SHARED_BUILDS_ROOT.iterdir(): + if stale == keep: + continue + same_fixture = stale.name.startswith(prefix) + if not same_fixture and not _unused_since(stale, cutoff): + continue + # Creating .lock bumps the dir mtime, so remember whether the re-probe + # under the lock can trust it + lock_preexisting = (stale / ".lock").exists() + try: + lock_file = (stale / ".lock").open("w") + except FileNotFoundError: + continue # pruned by another worker meanwhile + except NotADirectoryError: + print(f"Removing stray file {stale}") + stale.unlink(missing_ok=True) + continue + except OSError as err: + print(f"Cannot prune {stale}: {err}") + continue + with lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + continue # still in use by another run + # Re-probe under the lock: a worker freshens its dir before + # locking, so a just-claimed dir no longer looks unused. A dir + # whose .lock we just created cannot be held by anyone, and our + # own open bumped its mtime, so its pre-open probe stands + if ( + lock_preexisting + and not same_fixture + and not _unused_since(stale, cutoff) + ): + continue + # rmtree tolerates races; a leftover partial tree only costs a + # rebuild, since the ELF is deleted before every compile + try: + rmtree(stale) + except OSError as err: + print(f"Failed to prune {stale}: {err}") + + +async def _run_esphome_compile( + config_path: Path, cwd: Path, env: dict[str, str] +) -> None: + """Run `esphome compile`, retrying up to 3 times on a segfault.""" + max_retries = 3 + for attempt in range(max_retries): + # Compile using subprocess, inheriting stdout/stderr to show progress + proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "esphome", + "compile", + str(config_path), + cwd=cwd, + stdout=None, # Inherit stdout + stderr=None, # Inherit stderr + stdin=asyncio.subprocess.DEVNULL, + # Start in a new process group to isolate signal handling + start_new_session=True, + env=env, + close_fds=False, + ) + await proc.wait() + + if proc.returncode == 0: + break + if proc.returncode == -11 and attempt < max_retries - 1: + # Segfault (-11 = SIGSEGV), retry + print( + f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." + ) + await asyncio.sleep(1) # Brief pause before retry + continue + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) + + +def _resolve_compiled_binary(config_path: Path) -> Path: + """Load the config to learn the compiled ELF path (blocking, run in executor).""" + CORE.reset() # Reset CORE state between test runs + CORE.config_path = config_path + config = esphome.config.read_config( + {"command": "compile", "config": str(config_path)} + ) + if config is None: + raise RuntimeError(f"Failed to read config from {config_path}") + binary_path = get_elf_path() + if not binary_path.exists(): + raise RuntimeError(f"Compiled binary not found at {binary_path}") + return binary_path + + @pytest_asyncio.fixture async def compile_esphome( integration_test_dir: Path, - shared_platformio_cache: Path, + shared_library_cache: Path, + request: pytest.FixtureRequest, ) -> AsyncGenerator[CompileFunction]: """Compile an ESPHome configuration and return the binary path.""" async def _compile(config_path: Path) -> Path: - # Use the shared PlatformIO cache for faster compilation - # This avoids re-downloading dependencies for each test - env = _get_platformio_env(shared_platformio_cache) - - # Retry compilation up to 3 times if we get a segfault - max_retries = 3 - for attempt in range(max_retries): - # Compile using subprocess, inheriting stdout/stderr to show progress - proc = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "esphome", - "compile", - str(config_path), - cwd=integration_test_dir, - stdout=None, # Inherit stdout - stderr=None, # Inherit stderr - stdin=asyncio.subprocess.DEVNULL, - # Start in a new process group to isolate signal handling - start_new_session=True, - env=env, - close_fds=False, - ) - await proc.wait() - - if proc.returncode == 0: - # Success! - break - if proc.returncode == -11 and attempt < max_retries - 1: - # Segfault (-11 = SIGSEGV), retry - print( - f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." - ) - await asyncio.sleep(1) # Brief pause before retry - continue - # Other error or final retry - raise RuntimeError( - f"Failed to compile {config_path}, return code: {proc.returncode}. " - f"Run with 'pytest -s' to see compilation output." - ) - - # Load the config to get idedata (blocking call, must use executor) + env = _get_build_env() loop = asyncio.get_running_loop() - def _read_config_and_get_binary(): - CORE.reset() # Reset CORE state between test runs - CORE.config_path = config_path - config = esphome.config.read_config( - {"command": "compile", "config": str(config_path)} + name = _shared_yaml_name(request) + if name is None: + # Share the library download cache so a test never re-fetches + # what an earlier one already pulled from the registry + await loop.run_in_executor( + None, _link_library_cache, integration_test_dir, shared_library_cache + ) + await _run_esphome_compile(config_path, integration_test_dir, env) + return await loop.run_in_executor( + None, _resolve_compiled_binary, config_path ) - if config is None: - raise RuntimeError(f"Failed to read config from {config_path}") - # Get the compiled binary path - idedata = get_idedata(config) - return Path(idedata.firmware_elf_path) - - binary_path = await loop.run_in_executor(None, _read_config_and_get_binary) - - if not binary_path.exists(): - raise RuntimeError(f"Compiled binary not found at {binary_path}") - - return binary_path + # Shared fixture: build in a hash-keyed dir so tests sharing a config + # pay one full compile and later only a main.cpp (port) rebuild + relink + shared_dir = _shared_build_dir(name) + shared_dir.mkdir(parents=True, exist_ok=True) + # Freshen the dir before locking so a concurrent age sweep, which + # re-probes under the lock, never reaps a dir a worker just claimed; + # if a peer reaped it already, the guarded lock open recreates it + with suppress(FileNotFoundError): + os.utime(shared_dir) + if shared_dir not in _pruned_dirs: + _pruned_dirs.add(shared_dir) + await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir) + shared_config = shared_dir / f"{name}.yaml" + private_binary = integration_test_dir / f"{name}.elf" + content = await loop.run_in_executor(None, read_file, config_path) + if content != getattr(request.node, "_shared_yaml_content", None): + # The dir is keyed by the fixture source; a mutated config would be + # cached under a hash that does not describe it + raise RuntimeError( + "shared_yaml tests must compile the yaml_config content unmodified" + ) + # flock serializes concurrent xdist workers; closing the fd releases it. + # Hand-rolled rather than filelock.FileLock: non-blocking retries keep + # the wait cancellable, while a blocking acquire in an executor thread + # would survive test cancellation holding the fd + try: + lock_file = (shared_dir / ".lock").open("w") + except FileNotFoundError: + # A peer run pruning divergent hashes reaped the dir between our + # mkdir and this open; recreate it and pay a full rebuild + shared_dir.mkdir(parents=True, exist_ok=True) + lock_file = (shared_dir / ".lock").open("w") + with lock_file: + start = time.monotonic() + last_report = start + while True: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + now = time.monotonic() + if now - start > _SHARED_LOCK_TIMEOUT_S: + raise RuntimeError( + f"Timed out waiting for the {shared_dir} lock" + ) from None + if now - last_report >= _SHARED_LOCK_REPORT_S: + last_report = now + print( + f"Waited {now - start:.0f}s for another worker's " + f"build of {shared_dir.name}" + ) + await asyncio.sleep(_SHARED_LOCK_POLL_S) + # .built carries the ELF path of the last completed compile, so + # later workers skip the config re-read in _resolve_compiled_binary + stamp = shared_dir / ".built" + if (built := _shared_elf_paths.get(shared_dir)) is None: + built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir) + # Delete the ELF before compiling: whatever exists afterwards is + # this compile's output, so no staleness check is ever needed. + # With no usable stamp, sweep any leftover at the known layout + if built is not None: + built.unlink(missing_ok=True) + else: + # Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree + for leftover in shared_dir.rglob("program"): + if leftover.is_file(): + leftover.unlink() + await loop.run_in_executor( + None, write_file_if_changed, shared_config, content + ) + await loop.run_in_executor( + None, _link_library_cache, shared_dir, shared_library_cache + ) + await _run_esphome_compile(shared_config, shared_dir, env) + if built is None or not built.exists(): + built = await loop.run_in_executor( + None, _resolve_compiled_binary, shared_config + ) + _shared_elf_paths[shared_dir] = built + await loop.run_in_executor(None, write_file, stamp, str(built)) + # Copy out before unlocking: another worker may relink firmware.elf + # while this test is still running its private copy + await loop.run_in_executor(None, shutil.copy2, built, private_binary) + return private_binary yield _compile diff --git a/tests/integration/fixtures/api_action_timeout.yaml b/tests/integration/fixtures/api_action_timeout.yaml index 405d9d0e2b3..5dc6e1db050 100644 --- a/tests/integration/fixtures/api_action_timeout.yaml +++ b/tests/integration/fixtures/api_action_timeout.yaml @@ -1,9 +1,8 @@ esphome: name: api-action-timeout-test # Use a short timeout for testing (500ms instead of 30s) - platformio_options: - build_flags: - - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" + build_flags: + - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" host: diff --git a/tests/integration/fixtures/api_decode_wire_types.yaml b/tests/integration/fixtures/api_decode_wire_types.yaml new file mode 100644 index 00000000000..2bb5028730b --- /dev/null +++ b/tests/integration/fixtures/api_decode_wire_types.yaml @@ -0,0 +1,43 @@ +esphome: + name: api-decode-wire-types-test +host: +api: +logger: + level: DEBUG + +switch: + - platform: template + name: "Wire Switch" + optimistic: true + +output: + - platform: template + id: wire_dim + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Wire Light" + output: wire_dim + default_transition_length: 0s + effects: + - pulse: + name: Pulse + +text: + - platform: template + name: "Wire Text" + optimistic: true + mode: text + min_length: 0 + max_length: 255 + +number: + - platform: template + name: "Wire Number" + optimistic: true + min_value: -1000 + max_value: 1000 + step: 0.5 diff --git a/tests/integration/fixtures/api_empty_message_roundtrip.yaml b/tests/integration/fixtures/api_empty_message_roundtrip.yaml new file mode 100644 index 00000000000..daa4d5e60cd --- /dev/null +++ b/tests/integration/fixtures/api_empty_message_roundtrip.yaml @@ -0,0 +1,11 @@ +esphome: + name: api-empty-message-test +host: +api: +logger: + level: DEBUG + +switch: + - platform: template + name: "Empty Message Switch" + optimistic: true diff --git a/tests/integration/fixtures/api_encode_boundaries.yaml b/tests/integration/fixtures/api_encode_boundaries.yaml new file mode 100644 index 00000000000..e6842d2571c --- /dev/null +++ b/tests/integration/fixtures/api_encode_boundaries.yaml @@ -0,0 +1,58 @@ +esphome: + name: api-encode-boundaries-test + # Top-level area fills DeviceInfoResponse.suggested_area (field 16, a two-byte tag) + area: + id: kitchen_area + name: Kitchen + on_boot: + - sensor.template.publish: + id: zero_then_value + state: 0.0 + +host: +api: +logger: + level: DEBUG + +sensor: + - platform: template + name: "Zero Then Value" + id: zero_then_value + # Negative int32 takes the ten byte varint path + accuracy_decimals: -2 + update_interval: never + +text_sensor: + - platform: template + name: "Long Text" + id: long_text + update_interval: never + +number: + - platform: template + name: "Negative Number" + optimistic: true + min_value: -1000 + max_value: 1000 + step: 0.5 + initial_value: -123.5 + +select: + - platform: template + name: "Long Option Select" + optimistic: true + options: + - short + - "option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-when-the-list-entities-response-is-encoded-xxxxxxxxxx" + initial_option: short + +button: + - platform: template + name: "Publish Values" + on_press: + - sensor.template.publish: + id: zero_then_value + state: 12.5 + - text_sensor.template.publish: + id: long_text + state: !lambda return std::string(200, 'y'); diff --git a/tests/integration/fixtures/cache_init.yaml b/tests/integration/fixtures/cache_init.yaml deleted file mode 100644 index de208196cdf..00000000000 --- a/tests/integration/fixtures/cache_init.yaml +++ /dev/null @@ -1,10 +0,0 @@ -esphome: - name: cache-init - -host: - -api: - encryption: - key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w=" - -logger: diff --git a/tests/integration/fixtures/entity_missing_state.yaml b/tests/integration/fixtures/entity_missing_state.yaml new file mode 100644 index 00000000000..6297716c6be --- /dev/null +++ b/tests/integration/fixtures/entity_missing_state.yaml @@ -0,0 +1,78 @@ +esphome: + name: test-missing-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + +# None of these publish a state at startup, so each one starts out unknown +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true + restore_mode: DISABLED + +climate: + - platform: template + name: "Test Climate" + id: test_climate + optimistic: true + supported_modes: + - "OFF" + - HEAT + + # A configured initial state is a state, so this one starts out known + - platform: template + name: "Initial Climate" + id: initial_climate + optimistic: true + restore_mode: NO_RESTORE + supported_modes: + - "OFF" + - HEAT + initial_state: + mode: HEAT + +globals: + - id: lambda_mode_known + type: bool + initial_value: "false" + +water_heater: + - platform: template + name: "Test Water Heater" + id: test_water_heater + supported_modes: + - "OFF" + - ECO + + # Reports OFF, which is also the default, once the MCU-equivalent global says so + - platform: template + name: "Lambda Water Heater" + id: lambda_water_heater + supported_modes: + - "OFF" + - ECO + mode: |- + if (!id(lambda_mode_known)) { + return {}; + } + return water_heater::WATER_HEATER_MODE_OFF; + +button: + - platform: template + name: "Publish States" + id: publish_states_button + on_press: + - switch.turn_on: test_switch + - climate.template.publish: + id: test_climate + mode: HEAT + - water_heater.template.publish: + id: test_water_heater + mode: ECO + - globals.set: + id: lambda_mode_known + value: "true" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index 2097b2fbf9c..05449afe86c 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -1,8 +1,7 @@ esphome: name: fnv1-hash-object-id-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::fnv1_hash_object_id; diff --git a/tests/integration/fixtures/fnv1a_hash.yaml b/tests/integration/fixtures/fnv1a_hash.yaml index d9c80601b88..3da0042e50e 100644 --- a/tests/integration/fixtures/fnv1a_hash.yaml +++ b/tests/integration/fixtures/fnv1a_hash.yaml @@ -1,8 +1,7 @@ esphome: name: fnv1a-hash-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::fnv1a_hash; diff --git a/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml new file mode 100644 index 00000000000..57f3d57a010 --- /dev/null +++ b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml @@ -0,0 +1,13 @@ +esphome: + name: host-ota-test +host: +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +ota: + - platform: esphome + port: __OTA_PORT__ + encryption: +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml new file mode 100644 index 00000000000..c00954515b1 --- /dev/null +++ b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml @@ -0,0 +1,11 @@ +esphome: + name: host-ota-test +host: +api: + encryption: +ota: + - platform: esphome + port: __OTA_PORT__ +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 052de0a4e58..34b5be9fdcf 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.yaml @@ -6,6 +6,21 @@ logger: level: DEBUG output: + - platform: template + id: test_inferred_red + type: float + write_action: + - lambda: "" + - platform: template + id: test_inferred_green + type: float + write_action: + - lambda: "" + - platform: template + id: test_inferred_blue + type: float + write_action: + - lambda: "" - platform: template id: test_red type: float @@ -26,6 +41,11 @@ output: type: float write_action: - lambda: "" + - platform: template + id: test_no_restore_key_output + type: float + write_action: + - lambda: "" light: - platform: rgb @@ -55,3 +75,29 @@ light: color_mode: BRIGHTNESS state: false brightness: 0% + + # With neither restore_mode nor restore_state configured, initial_state: must be + # honored as-is (no implicit forcing to OFF) -- unlike every explicit restore_mode: + # value, which always overrides `state` regardless of initial_state. + - platform: monochromatic + name: "Test No Restore Key Light" + id: test_no_restore_key_light + output: test_no_restore_key_output + initial_state: + color_mode: BRIGHTNESS + state: true + brightness: 60% + + # No color_mode: it must be inferred from the colour fields, not left unknown (which + # would drop the colours and leave the light full white). + - platform: rgb + name: "Test Inferred Color Mode Light" + id: test_inferred_color_mode_light + red: test_inferred_red + green: test_inferred_green + blue: test_inferred_blue + initial_state: + state: true + red: 30% + green: 0% + blue: 0% diff --git a/tests/integration/fixtures/light_restore_state.yaml b/tests/integration/fixtures/light_restore_state.yaml new file mode 100644 index 00000000000..ac928a76c60 --- /dev/null +++ b/tests/integration/fixtures/light_restore_state.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-light-restore-state-test +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: out_keep + type: float + write_action: + - lambda: "" + - platform: template + id: out_override + type: float + write_action: + - lambda: "" + +light: + # restore_state: {} -- restore everything exactly as saved, no overrides + - platform: monochromatic + name: "Test Light Keep" + id: test_light_keep + output: out_keep + restore_state: {} + + # restore_state: with explicit overrides -- these must win over whatever was saved + - platform: monochromatic + name: "Test Light Override" + id: test_light_override + output: out_override + restore_state: + state: INVERT + brightness: 100% diff --git a/tests/integration/fixtures/light_resume_effect.yaml b/tests/integration/fixtures/light_resume_effect.yaml new file mode 100644 index 00000000000..e52dc1ea677 --- /dev/null +++ b/tests/integration/fixtures/light_resume_effect.yaml @@ -0,0 +1,47 @@ +esphome: + name: light-resume-effect + +host: + +api: + +logger: + level: DEBUG + +output: + - platform: template + id: light_output + type: float + write_action: + - logger.log: + format: "LIGHT_OUTPUT:%.2f" + args: [state] + +light: + - platform: monochromatic + name: "Test Light Resume" + id: test_light_resume + output: light_output + resume_effect: true + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms + - strobe: + name: "Strobe Effect" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms + - platform: monochromatic + name: "Test Light No Resume" + id: test_light_no_resume + output: light_output + resume_effect: false + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms diff --git a/tests/integration/fixtures/light_transition_interval_save.yaml b/tests/integration/fixtures/light_transition_interval_save.yaml new file mode 100644 index 00000000000..8f32b56e6d4 --- /dev/null +++ b/tests/integration/fixtures/light_transition_interval_save.yaml @@ -0,0 +1,38 @@ +esphome: + name: ltspi-save +host: +api: +logger: + +preferences: + flash_write_interval: 0s # Flush to disk on every loop + +output: + - platform: template + id: mono_output + type: float + write_action: + - logger.log: + format: "mono_output: %.2f" + args: [state] + +light: + - platform: monochromatic + id: test_mono_light + name: "Test Mono Light" + output: mono_output + restore_mode: RESTORE_DEFAULT_OFF + default_transition_length: 1s + transition_state_publish_interval: 0.2s + +button: + - platform: template + id: run_persistence_transition + name: "Run Persistence Transition" + on_press: + - lambda: |- + auto call = id(test_mono_light).turn_on(); + call.set_brightness(1.0f); + call.set_transition_length(1000); + call.set_save(true); + call.perform(); diff --git a/tests/integration/fixtures/light_transition_state_publish_interval.yaml b/tests/integration/fixtures/light_transition_state_publish_interval.yaml new file mode 100644 index 00000000000..85627c1738c --- /dev/null +++ b/tests/integration/fixtures/light_transition_state_publish_interval.yaml @@ -0,0 +1,121 @@ +esphome: + name: ltspi +host: +api: +logger: + +output: + # Monochromatic output + - platform: template + id: mono_output + type: float + write_action: + - logger.log: + format: "mono_output: %.2f" + args: [state] + # RGB outputs + - platform: template + id: rgb_red + type: float + write_action: + - logger.log: + format: "rgb_red: %.2f" + args: [state] + - platform: template + id: rgb_green + type: float + write_action: + - logger.log: + format: "rgb_green: %.2f" + args: [state] + - platform: template + id: rgb_blue + type: float + write_action: + - logger.log: + format: "rgb_blue: %.2f" + args: [state] + # CWWW outputs (using rgbww light for CT + cold/warm white coverage) + - platform: template + id: cwww_red + type: float + write_action: + - logger.log: + format: "cwww_red: %.2f" + args: [state] + - platform: template + id: cwww_green + type: float + write_action: + - logger.log: + format: "cwww_green: %.2f" + args: [state] + - platform: template + id: cwww_blue + type: float + write_action: + - logger.log: + format: "cwww_blue: %.2f" + args: [state] + - platform: template + id: cwww_cold_white + type: float + write_action: + - logger.log: + format: "cwww_cold_white: %.2f" + args: [state] + - platform: template + id: cwww_warm_white + type: float + write_action: + - logger.log: + format: "cwww_warm_white: %.2f" + args: [state] + - platform: template + id: legacy_output + type: float + write_action: + - logger.log: + format: "legacy_output: %.2f" + args: [state] + +light: + # Monochromatic light (brightness only) + - platform: monochromatic + id: test_mono_light + name: "Test Mono Light" + output: mono_output + default_transition_length: 1s + transition_state_publish_interval: 0.2s + + # RGB light + - platform: rgb + id: test_rgb_light + name: "Test RGB Light" + red: rgb_red + green: rgb_green + blue: rgb_blue + default_transition_length: 1s + transition_state_publish_interval: 0.2s + + # CWWW/CT-capable light + - platform: rgbww + id: test_cwww_light + name: "Test CWWW Light" + red: cwww_red + green: cwww_green + blue: cwww_blue + cold_white: cwww_cold_white + warm_white: cwww_warm_white + cold_white_color_temperature: 6500 K + warm_white_color_temperature: 2000 K + constant_brightness: true + default_transition_length: 1s + transition_state_publish_interval: 0.2s + + # No interval: publishes only when a call starts + - platform: monochromatic + id: test_legacy_light + name: "Test Legacy Light" + output: legacy_output + default_transition_length: 1s diff --git a/tests/integration/fixtures/micros_to_millis.yaml b/tests/integration/fixtures/micros_to_millis.yaml index d11808c43a2..a7ba1c719a0 100644 --- a/tests/integration/fixtures/micros_to_millis.yaml +++ b/tests/integration/fixtures/micros_to_millis.yaml @@ -1,8 +1,7 @@ esphome: name: micros-to-millis-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::micros_to_millis; diff --git a/tests/integration/fixtures/sensor_filters_batch_window.yaml b/tests/integration/fixtures/sensor_filters_batch_window.yaml deleted file mode 100644 index 58a254c215c..00000000000 --- a/tests/integration/fixtures/sensor_filters_batch_window.yaml +++ /dev/null @@ -1,58 +0,0 @@ -esphome: - name: test-batch-window-filters - -host: -api: - batch_delay: 0ms # Disable batching to receive all state updates -logger: - level: DEBUG - -# Template sensor that we'll use to publish values -sensor: - - platform: template - name: "Source Sensor" - id: source_sensor - accuracy_decimals: 2 - - # Batch window filters (window_size == send_every) - use streaming filters - - platform: copy - source_id: source_sensor - name: "Min Sensor" - id: min_sensor - filters: - - min: - window_size: 5 - send_every: 5 - send_first_at: 1 - - - platform: copy - source_id: source_sensor - name: "Max Sensor" - id: max_sensor - filters: - - max: - window_size: 5 - send_every: 5 - send_first_at: 1 - - - platform: copy - source_id: source_sensor - name: "Moving Avg Sensor" - id: moving_avg_sensor - filters: - - sliding_window_moving_average: - window_size: 5 - send_every: 5 - send_first_at: 1 - -# Button to trigger publishing test values -button: - - platform: template - name: "Publish Values Button" - id: publish_button - on_press: - - lambda: |- - // Publish 10 values: 1.0, 2.0, ..., 10.0 - for (int i = 1; i <= 10; i++) { - id(source_sensor).publish_state(float(i)); - } diff --git a/tests/integration/fixtures/sensor_raw_state.yaml b/tests/integration/fixtures/sensor_raw_state.yaml new file mode 100644 index 00000000000..9c19032028f --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state.yaml @@ -0,0 +1,53 @@ +esphome: + name: test-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Filters are compiled in for this config (USE_SENSOR_FILTER), so raw storage exists +sensor: + # No filters on this sensor: get_raw_state() must equal state + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + + # Filtered sensor: get_raw_state() must be the pre-filter value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + accuracy_decimals: 1 + filters: + - multiply: 2.0 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - sensor.template.publish: + id: with_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "WITH_FILTER: state=%.1f raw_state=%.1f" + args: + - id(with_filter_sensor).state + - id(with_filter_sensor).get_raw_state() diff --git a/tests/integration/fixtures/sensor_raw_state_no_filter.yaml b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml new file mode 100644 index 00000000000..fec912691f2 --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml @@ -0,0 +1,31 @@ +esphome: + name: test-sensor-raw-state-no-filter + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# No sensor in this config has filters, so USE_SENSOR_FILTER is not defined and +# get_raw_state() falls back to state +sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() diff --git a/tests/integration/fixtures/set_internal_at_boot.yaml b/tests/integration/fixtures/set_internal_at_boot.yaml new file mode 100644 index 00000000000..b3007e9dbda --- /dev/null +++ b/tests/integration/fixtures/set_internal_at_boot.yaml @@ -0,0 +1,34 @@ +esphome: + name: set-internal-at-boot + on_boot: + then: + - lambda: |- + id(hidden_at_boot).set_internal(true); + id(shown_at_boot).set_internal(false); + +host: + +api: + actions: + - action: set_internal_late + then: + - lambda: id(untouched).set_internal(true); + +logger: + +sensor: + - platform: template + name: "Hidden At Boot" + id: hidden_at_boot + lambda: return 1.0; + + - platform: template + name: "Shown At Boot" + id: shown_at_boot + internal: true + lambda: return 2.0; + + - platform: template + name: "Untouched" + id: untouched + lambda: return 3.0; diff --git a/tests/integration/fixtures/snapshot_display.yaml b/tests/integration/fixtures/snapshot_display.yaml index d10af098069..894ad03d40d 100644 --- a/tests/integration/fixtures/snapshot_display.yaml +++ b/tests/integration/fixtures/snapshot_display.yaml @@ -14,6 +14,47 @@ api: id: snapshot_display filename: !lambda return name; + - action: take_animation + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + frames: 5 + frame_rate: 20fps + + - action: take_noise_snapshot + variables: + name: string + then: + - snapshot.take: + id: noise_display + filename: !lambda return name; + + # A busy picture needs more compression codes than a GIF dictionary holds, so recording it makes + # the encoder widen its codes and start the dictionary over. + - action: take_noise_animation + variables: + name: string + then: + - snapshot.take: + id: noise_display + filename: !lambda return name; + frames: 3 + frame_rate: 20fps + + # Slow enough that a second request arrives while this one is still recording. + - action: take_slow_animation + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + frames: 3 + frame_rate: 2fps + logger: level: DEBUG @@ -26,3 +67,23 @@ display: dimensions: width: 101 height: 64 + + # Every pixel is one of 256 colours picked by a hash of its position, so the picture is + # effectively random but fits a GIF palette exactly. + - platform: snapshot + id: noise_display + update_interval: 1s + dimensions: + width: 200 + height: 150 + lambda: |- + for (int y = 0; y != it.get_height(); y++) { + for (int x = 0; x != it.get_width(); x++) { + uint32_t h = static_cast(x) * 73856093u ^ static_cast(y) * 19349663u; + h ^= h >> 13; + h *= 0x5bd1e995u; + h ^= h >> 15; + uint8_t c = h & 0xFF; + it.draw_pixel_at(x, y, Color(c & 0xE0, (c << 3) & 0xE0, (c << 6) & 0xC0)); + } + } diff --git a/tests/integration/fixtures/syslog.yaml b/tests/integration/fixtures/syslog.yaml index df376087e32..dd48938a0a1 100644 --- a/tests/integration/fixtures/syslog.yaml +++ b/tests/integration/fixtures/syslog.yaml @@ -41,3 +41,4 @@ syslog: level: DEBUG strip: true facility: 16 + format: SYSLOG_FORMAT_PLACEHOLDER diff --git a/tests/integration/fixtures/template_time.yaml b/tests/integration/fixtures/template_time.yaml new file mode 100644 index 00000000000..d55e8fe6562 --- /dev/null +++ b/tests/integration/fixtures/template_time.yaml @@ -0,0 +1,47 @@ +esphome: + name: template-time-test +host: +api: +logger: + +time: + - platform: template + id: fixed_time + lambda: |- + return 1700000000; + + # Derived from another time source, as when offsetting a real clock. + - platform: template + id: offset_time + lambda: |- + return id(fixed_time).timestamp_now() + 3600; + + # A lambda that gives no value reports an invalid time. + - platform: template + id: empty_time + lambda: |- + return {}; + +text_sensor: + # Timestamps are exposed as text (rather than a 32-bit float sensor state, + # which cannot represent a UNIX epoch exactly) so the exact value can be checked. + - platform: template + name: "Fixed Timestamp" + id: fixed_timestamp + update_interval: 100ms + lambda: |- + return std::to_string(id(fixed_time).now().timestamp); + + - platform: template + name: "Offset Timestamp" + id: offset_timestamp + update_interval: 100ms + lambda: |- + return std::to_string(id(offset_time).now().timestamp); + + - platform: template + name: "Empty Time Valid" + id: empty_time_valid + update_interval: 100ms + lambda: |- + return std::string(id(empty_time).now().is_valid() ? "valid" : "invalid"); diff --git a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml deleted file mode 100644 index 1f89889c958..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml +++ /dev/null @@ -1,111 +0,0 @@ -esphome: - name: uart-mock-modbus-cli-rw - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -# Two virtual buses looped back to each other: the client's transmissions reach the server and the -# server's replies reach the client. auto_start so forwarding is active before the button fires. -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_client - data: !lambda return data; - - id: virtual_uart_client - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_1 - type: uint16_t - initial_value: "0" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_client - id: virtual_modbus_client - role: client - turnaround_time: 10ms - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - registers: - # Writable + readable register: the read publishes what it returns, so the test can confirm the - # write half of the 0x17 ran before the read half (Modbus 6.17). - - address: 0x01 - value_type: U_WORD - read_lambda: |- - id(srv_read_1).publish_state(id(stored_1)); - return id(stored_1); - write_lambda: |- - id(stored_1) = x; - id(srv_write_1).publish_state(x); - return true; - # Read-only register, returned together with 0x01 by the 2-register read half. - - address: 0x02 - value_type: U_WORD - read_lambda: return 0x00AA; - -sensor: - # Server-side observations. - - platform: template - name: "srv_write_1" - id: srv_write_1 - - platform: template - name: "srv_read_1" - id: srv_read_1 - # Client-side read-back: the values the client's on_response received. - - platform: template - name: "client_read_0" - id: client_read_0 - - platform: template - name: "client_read_1" - id: client_read_1 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - on_press: - # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. - - modbus_client.read_write_multiple_registers: - address: 0x01 - read_address: 0x0001 - read_count: 2 - write_address: 0x0001 - values: [0x1234] - on_response: - then: - - lambda: |- - // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. - if (values.size() >= 2) { - id(client_read_0).publish_state(values[0]); - id(client_read_1).publish_state(values[1]); - } diff --git a/tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml b/tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml deleted file mode 100644 index 188abf90f18..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml +++ /dev/null @@ -1,88 +0,0 @@ -esphome: - name: uart-mock-modbus-custom-pdu - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 259; - -sensor: - # Plain read to confirm the controller <-> server link is up. - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "plain_read" - address: 0x01 - register_type: holding - value_type: U_WORD - # Custom PDU: read holding register 0x0001, count 1. The PDU is - # {function code, address hi, address lo, count hi, count lo}; the device - # address and CRC are added by the hub. The lambda parses the response payload - # (the register value, big-endian). - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "custom_read" - custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01] - lambda: |- - if (data.size() < 2) return {}; - return (float) ((data[0] << 8) | data[1]); - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml b/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml deleted file mode 100644 index f378e3de433..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml +++ /dev/null @@ -1,106 +0,0 @@ -esphome: - name: uart-mock-modbus-dep-buffer - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg10 - type: uint16_t - initial_value: "0" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x10 - value_type: U_WORD - read_lambda: return id(reg10); - write_lambda: |- - id(reg10) = x; - return true; - -# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw -# frame as words: device address + function code + data) instead of the new item->write_* API. The write -# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per -# entity no matter how many writes happen. -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "buf_number" - id: buf_number - address: 0x10 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 1000 - step: 1 - write_lambda: |- - // Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value. - payload.push_back(0x0106); - payload.push_back(0x0010); - payload.push_back((uint16_t) x); - return {}; - -# Reports the server-side register so the test can observe that the deprecated buffer write landed. -sensor: - - platform: template - name: "written_value" - id: written_value - update_interval: 0.5s - lambda: "return id(reg10);" - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # The test drives the writes via number_command; the mock is autostart. diff --git a/tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml b/tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml deleted file mode 100644 index 41afce70d6e..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml +++ /dev/null @@ -1,95 +0,0 @@ -esphome: - name: uart-mock-modbus-lambda-invert - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg40 - type: uint16_t - initial_value: "5" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x40 - value_type: U_WORD - read_lambda: return id(reg40); - write_lambda: id(reg40) = x; return true; - -# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still -# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes -# only from write_state() - turning ON writes 0x0000 yet the switch shows ON. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "invert_switch" - register_type: holding - address: 0x40 - assumed_state: true - write_lambda: |- - return !x; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_40" - address: 0x40 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml b/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml deleted file mode 100644 index 86e17ea0d73..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml +++ /dev/null @@ -1,97 +0,0 @@ -esphome: - name: uart-mock-modbus-lambda-write - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg30 - type: uint16_t - initial_value: "0" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x30 - value_type: U_WORD - read_lambda: return id(reg30); - write_lambda: id(reg30) = x; return true; - -# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead -# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so -# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing -# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "cross_switch" - register_type: coil - address: 0x00 - assumed_state: true - write_lambda: |- - item->write_single_register(0x30, x ? 1234 : 0); - return {}; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_30" - address: 0x30 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_loopback.yaml b/tests/integration/fixtures/uart_mock_modbus_loopback.yaml new file mode 100644 index 00000000000..7212bfb2b24 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_loopback.yaml @@ -0,0 +1,233 @@ +esphome: + name: uart-mock-modbus-loopback + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Shared loopback fixture (see the shared_yaml markers in the test file); +# register spaces are disjoint so each test only observes its own entities. +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: reg10 + type: uint16_t + initial_value: "100" + - id: reg11 + type: uint16_t + initial_value: "200" + - id: reg12 + type: uint16_t + initial_value: "300" + - id: reg13 + type: uint16_t + initial_value: "0xABCD" + - id: reg30 + type: uint16_t + initial_value: "0" + - id: reg40 + type: uint16_t + initial_value: "5" + - id: reg50 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 259; + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: id(reg10) = x; return true; + - address: 0x11 + value_type: U_WORD + read_lambda: return id(reg11); + write_lambda: id(reg11) = x; return true; + - address: 0x12 + value_type: U_WORD + read_lambda: return id(reg12); + write_lambda: id(reg12) = x; return true; + - address: 0x13 + value_type: U_WORD + read_lambda: return id(reg13); + - address: 0x30 + value_type: U_WORD + read_lambda: return id(reg30); + write_lambda: id(reg30) = x; return true; + - address: 0x40 + value_type: U_WORD + read_lambda: return id(reg40); + write_lambda: id(reg40) = x; return true; + - address: 0x50 + value_type: U_WORD + read_lambda: return id(reg50); + write_lambda: id(reg50) = x; return true; + +# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a +# register count, hitting 0x12). assumed_state keeps the switch write-only. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "offset_switch" + register_type: holding + address: 0x10 + offset: 2 + assumed_state: true + # Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16) + # would draw ILLEGAL_DATA_ADDRESS and never publish. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "read_offset_switch" + register_type: holding + address: 0x10 + offset: 6 + bitmask: 0x1 + # Coil switch whose write_lambda dispatches a holding-register write via `item`; + # returning an empty optional suppresses the default coil write. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "cross_switch" + register_type: coil + address: 0x00 + assumed_state: true + write_lambda: |- + item->write_single_register(0x30, x ? 1234 : 0); + return {}; + # Active-low: the write_lambda inverts the wire value but the entity must still + # report the requested state (assumed_state keeps the register unpolled). + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "invert_switch" + register_type: holding + address: 0x40 + assumed_state: true + write_lambda: |- + return !x; + +# Uses the deprecated buffer parameter (legacy raw frame as words); the write +# must land and the deprecation warning must fire only once per entity. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "buf_number" + id: buf_number + address: 0x50 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 1000 + step: 1 + write_lambda: |- + // Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value. + payload.push_back(0x0106); + payload.push_back(0x0050); + payload.push_back((uint16_t) x); + return {}; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "plain_read" + address: 0x01 + register_type: holding + value_type: U_WORD + # Custom PDU: read holding register 0x0001; device address and CRC are added + # by the hub. The lambda parses the big-endian register value. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "custom_read" + custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01] + lambda: |- + if (data.size() < 2) return {}; + return (float) ((data[0] << 8) | data[1]); + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_10" + address: 0x10 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_11" + address: 0x11 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_12" + address: 0x12 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_30" + address: 0x30 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_40" + address: 0x40 + register_type: holding + value_type: U_WORD + # Reports the server-side register so the test can observe that the deprecated buffer write landed. + - platform: template + name: "written_value" + id: written_value + update_interval: 0.5s + lambda: "return id(reg50);" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # Nothing to start (mock is autostart); tests drive entities directly diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml similarity index 58% rename from tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml rename to tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 5ade49bd48c..977cdd359be 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -1,5 +1,5 @@ esphome: - name: uart-mock-modbus-srv-write + name: uart-mock-modbus-mesh host: api: @@ -17,13 +17,14 @@ uart: baud_rate: 115200 port: /dev/null +# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers +# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6 +# on the second server hub. auto_start everywhere: the controller polls at +# boot, so the forwarding must already be live or early requests generate warnings. +# Every test presses Start Scenario, so all merged actions fire in every test. uart_mock: - id: virtual_uart_server baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. auto_start: true debug: on_tx: @@ -31,79 +32,120 @@ uart_mock: - uart_mock.inject_rx: id: virtual_uart_controller data: !lambda return data; - - id: virtual_uart_controller + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true debug: on_tx: - then: - uart_mock.inject_rx: id: virtual_uart_server data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; globals: + - id: stored_1 + type: uint16_t + initial_value: "0" - id: stored_u_word type: uint16_t - initial_value: "11" + initial_value: "99" - id: stored_u_word_s type: uint16_t initial_value: "4660" - id: stored_s_word type: int16_t - initial_value: "-11" + initial_value: "-99" - id: stored_s_word_s type: int16_t initial_value: "-2" - id: stored_u_dword type: uint32_t - initial_value: "1001" + initial_value: "16909060" - id: stored_s_dword type: int32_t - initial_value: "-1001" + initial_value: "-16909060" - id: stored_u_dword_r type: uint32_t - initial_value: "3003" + initial_value: "67305985" - id: stored_s_dword_r type: int32_t - initial_value: "-3003" + initial_value: "-67305985" - id: stored_u_qword type: uint64_t - initial_value: "5005" + initial_value: "72623859790382856" - id: stored_s_qword type: int64_t - initial_value: "-5005" + initial_value: "-72623859790382856" - id: stored_u_qword_r type: uint64_t - initial_value: "7007" + initial_value: "578437695752307201" - id: stored_s_qword_r type: int64_t - initial_value: "-7007" + initial_value: "-578437695752307201" - id: stored_fp32 type: float - initial_value: "1.5" + initial_value: "3.14" - id: stored_fp32_r type: float initial_value: "2.5" + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" modbus: - uart_id: virtual_uart_server id: virtual_modbus_server role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server - uart_id: virtual_uart_controller - id: virtual_modbus_controller + id: virtual_modbus_client role: client turnaround_time: 10ms modbus_controller: - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 2s + modbus_id: virtual_modbus_client id: modbus_controller_1 + update_interval: 1s + - address: 2 + modbus_id: virtual_modbus_client + id: modbus_controller_2 + update_interval: 1s + - address: 3 + modbus_id: virtual_modbus_client + id: modbus_controller_3 + update_interval: 1s + - address: 6 + modbus_id: virtual_modbus_client + id: modbus_controller_6 + update_interval: 1s modbus_server: - address: 1 modbus_id: virtual_modbus_server - id: modbus_server_1 registers: - address: 0x01 value_type: U_WORD @@ -161,6 +203,47 @@ modbus_server: value_type: FP32_R read_lambda: return id(stored_fp32_r); write_lambda: id(stored_fp32_r) = x; return true; + - address: 5 + modbus_id: virtual_modbus_server + registers: + # Writable + readable register: srv_write_1 plus the client's read-back + # confirm the write half of the 0x17 ran before the read half (Modbus 6.17). + - address: 0x01 + value_type: U_WORD + read_lambda: return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(srv_write_1).publish_state(x); + return true; + # Read-only register, returned together with 0x01 by the 2-register read half. + - address: 0x02 + value_type: U_WORD + read_lambda: return 0x00AA; + - address: 2 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 3 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 929; + - address: 6 + modbus_id: virtual_modbus_server_2 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; sensor: - platform: modbus_controller @@ -175,6 +258,12 @@ sensor: address: 0x02 register_type: holding value_type: U_WORD_S + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s_raw" + address: 0x02 + register_type: holding + value_type: U_WORD - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_s_word" @@ -247,7 +336,31 @@ sensor: address: 0x28 register_type: holding value_type: FP32_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: "multi_reg_a" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_3 + name: "multi_reg_b" + address: 0x01 + register_type: holding + value_type: U_WORD + # client_read_write observations, server- and client-side. + - platform: template + name: "srv_write_1" + id: srv_write_1 + - platform: template + name: "client_read_0" + id: client_read_0 + - platform: template + name: "client_read_1" + id: client_read_1 +# The number schema caps min/max at 16777215 (float32 integer precision), so +# the large dword/qword baselines cannot be written back through these numbers. number: - platform: modbus_controller modbus_controller_id: modbus_controller_1 @@ -364,8 +477,82 @@ number: max_value: 16777215 step: 0.01 +# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02); +# the server serves both from one shared table, so the two views must agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + button: - platform: template name: "Start Scenario" id: start_scenario_btn - # This test does not have anything to start (mock is autostart) + on_press: + # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. + - modbus_client.read_write_multiple_registers: + address: 5 + read_address: 0x0001 + read_count: 2 + write_address: 0x0001 + values: [0x1234] + on_response: + then: + - lambda: |- + // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. + if (values.size() >= 2) { + id(client_read_0).publish_state(values[0]); + id(client_read_1).publish_state(values[1]); + } diff --git a/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml b/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml deleted file mode 100644 index 21c451aa992..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml +++ /dev/null @@ -1,138 +0,0 @@ -esphome: - name: uart-mock-modbus-reg-offset - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg10 - type: uint16_t - initial_value: "100" - - id: reg11 - type: uint16_t - initial_value: "200" - - id: reg12 - type: uint16_t - initial_value: "300" - - id: reg13 - type: uint16_t - initial_value: "0xABCD" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x10 - value_type: U_WORD - read_lambda: return id(reg10); - write_lambda: id(reg10) = x; return true; - - address: 0x11 - value_type: U_WORD - read_lambda: return id(reg11); - write_lambda: id(reg11) = x; return true; - - address: 0x12 - value_type: U_WORD - read_lambda: return id(reg12); - write_lambda: id(reg12) = x; return true; - - address: 0x13 - value_type: U_WORD - read_lambda: return id(reg13); - write_lambda: id(reg13) = x; return true; - -# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target -# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register -# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "offset_switch" - register_type: holding - address: 0x10 - offset: 2 - assumed_state: true - # A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix - # the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and - # joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds - # into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes. - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "read_offset_switch" - register_type: holding - address: 0x10 - offset: 6 - bitmask: 0x1 - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_10" - address: 0x10 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_11" - address: 0x11 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_12" - address: 0x12 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml deleted file mode 100644 index cc5a59e242a..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server.yaml +++ /dev/null @@ -1,124 +0,0 @@ -esphome: - name: uart-mock-modbus-server-test - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_dev - baud_rate: 9600 - rx_full_threshold: 120 - rx_timeout: 2 - auto_start: false - debug: - injections: - - delay: 100ms - inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) - - delay: 100ms - # Read holding register 7 on device 2 - # Reply from device 2 - # Read holding register 5 on device 1 (read_after_peer_response) - inject_rx: - [ - 0x02, - 0x03, - 0x00, - 0x07, - 0x00, - 0x01, - 0x35, - 0xF8, - 0x02, - 0x03, - 0x02, - 0x00, - 0xF0, - 0xFC, - 0x00, - 0x01, - 0x03, - 0x00, - 0x05, - 0x00, - 0x01, - 0x94, - 0x0B, - ] - - delay: 100ms - inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response - - delay: 100ms - # Read holding register 7 on device 2, with no response - # Read holding register A on device 1 (read_after_peer_timeout) - inject_rx: - [ - 0x02, - 0x03, - 0x00, - 0x07, - 0x00, - 0x01, - 0x35, - 0xF8, - 0x01, - 0x03, - 0x00, - 0x0A, - 0x00, - 0x01, - 0xA4, - 0x08, - ] - -modbus: - uart_id: virtual_uart_dev - role: server - -modbus_server: - - address: 1 - registers: - - address: 0x03 - value_type: U_WORD - read_lambda: |- - id(basic_read).publish_state(1); - return 1; - - address: 0x05 - value_type: U_WORD - read_lambda: |- - id(read_after_peer_response).publish_state(1); - return 1; - - address: 0x0A - value_type: U_WORD - read_lambda: |- - id(read_after_peer_timeout).publish_state(1); - return 1; - -sensor: - - platform: template - name: "basic_read" - id: basic_read - - platform: template - name: "read_after_peer_response" - id: read_after_peer_response - - platform: template - name: "read_after_peer_timeout" - id: read_after_peer_timeout - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml deleted file mode 100644 index 4a5d280a2f0..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ /dev/null @@ -1,203 +0,0 @@ -esphome: - name: uart-mock-modbus-server-contro - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 99; - - address: 0x02 - value_type: U_WORD_S - read_lambda: return 4660; - - address: 0x03 - value_type: S_WORD - read_lambda: return -99; - - address: 0x04 - value_type: S_WORD_S - read_lambda: return -2; - - address: 0x05 - value_type: U_DWORD - read_lambda: return 16909060; - - address: 0x08 - value_type: S_DWORD - read_lambda: return -16909060; - - address: 0x0B - value_type: U_DWORD_R - read_lambda: return 67305985; - - address: 0x0E - value_type: S_DWORD_R - read_lambda: return -67305985; - - address: 0x11 - value_type: U_QWORD - read_lambda: return 72623859790382856; - - address: 0x16 - value_type: S_QWORD - read_lambda: return -72623859790382856; - - address: 0x1B - value_type: U_QWORD_R - read_lambda: return 578437695752307201; - - address: 0x20 - value_type: S_QWORD_R - read_lambda: return -578437695752307201; - - address: 0x25 - value_type: FP32 - read_lambda: return 3.14; - - address: 0x28 - value_type: FP32_R - read_lambda: return 3.14; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word_s_raw" - address: 0x02 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml deleted file mode 100644 index cb6fc6f0740..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml +++ /dev/null @@ -1,147 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-bits - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_bit_2 - type: bool - initial_value: "false" - - id: stored_bit_3 - type: bool - initial_value: "true" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 1s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - bits: - - address: 0x00 - read_lambda: return true; - - address: 0x01 - read_lambda: return false; - - address: 0x02 - read_lambda: return id(stored_bit_2); - write_lambda: id(stored_bit_2) = x; return true; - - address: 0x03 - read_lambda: return id(stored_bit_3); - write_lambda: id(stored_bit_3) = x; return true; - -# The same four bits are read both as coils (FC 0x01) and as discrete inputs -# (FC 0x02): the server serves both from one shared bit table, so the two -# views must always agree. -binary_sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_0" - address: 0x00 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_1" - address: 0x01 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_3" - address: 0x03 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_0" - address: 0x00 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_1" - address: 0x01 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_2" - address: 0x02 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_3" - address: 0x03 - register_type: discrete_input - -# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the -# multiple-coils write (FC 0x0F) so both server write paths are exercised. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_3" - address: 0x03 - register_type: coil - use_write_multiple: true - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml deleted file mode 100644 index 18423be6d58..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ /dev/null @@ -1,116 +0,0 @@ -esphome: - name: uart-mock-modbus-server-mult - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - uart_mock.inject_rx: - id: virtual_uart_server_2 - data: !lambda return data; - - id: virtual_uart_server_2 - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - - uart_mock.inject_rx: - id: virtual_uart_server_2 - data: !lambda return data; - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_server_2 - id: virtual_modbus_server_2 - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_client - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_client - update_interval: 1s - id: modbus_controller_1 - - address: 2 - modbus_id: virtual_modbus_client - update_interval: 1s - id: modbus_controller_2 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 919; - - address: 2 - modbus_id: virtual_modbus_server_2 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 929; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_2 - name: "reg_u_word_2" - address: 0x01 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml new file mode 100644 index 00000000000..8b2113ccd9e --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml @@ -0,0 +1,213 @@ +esphome: + name: uart-mock-modbus-srv-injected + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Shared server-role fixture (see the shared_yaml markers in the test file); +# the injections concatenate and each test waits only on its own sensors. +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + # Each burst-case reply from device 1 (single-register FC 0x03, told apart by its register + # value) fires its own sensor when it reaches the wire. One sensor per reply, because the API + # merges updates to the same entity that land within its batching window. + on_tx: + - then: + - lambda: |- + if (data.size() != 7 || data[0] != 0x01 || data[1] != 0x03 || data[3] != 0x00) + return; + switch (data[4]) { + case 0xA1: id(burst_tx_a).publish_state(1); break; + case 0xB2: id(burst_tx_b).publish_state(1); break; + case 0xC3: id(burst_tx_before_peer).publish_state(1); break; + case 0xD4: id(burst_tx_probe).publish_state(1); break; + } + injections: + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) + - delay: 100ms + # Read holding register 7 on device 2, its reply, then read holding + # register 5 on device 1 (read_after_peer_response) + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8, + 0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC, + 0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B] + - delay: 100ms + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response + - delay: 100ms + # Read holding register 7 on device 2 with no response, then read + # holding register A on device 1 (read_after_peer_timeout) + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8, + 0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08] + # FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002; + # per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] + # FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) - + # a write and read targeting a different register block. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55] + # Two reads of device 1 (regs 0x0B then 0x0C) in one injection so both land in the rx buffer + # together. The reply to 0x0B is deferred because 0x0C is still queued behind it, and must be + # dropped once 0x0C is parsed: only the reply to 0x0C may reach the wire (burst_read_a/b). + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0B, 0x00, 0x01, 0xF5, 0xC8, + 0x01, 0x03, 0x00, 0x0C, 0x00, 0x01, 0x44, 0x09] + # Read of device 1 (reg 0x0D) followed in the same injection by a read of device 2. The client + # has moved on to another device, so the deferred reply to 0x0D must never be sent + # (burst_read_before_peer). + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0D, 0x00, 0x01, 0x15, 0xC9, + 0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] + # Plain read of device 1 (reg 0x0E) whose reply on the wire marks the burst cases as settled. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0E, 0x00, 0x01, 0xE5, 0xC9] + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + - id: stored_3 + type: uint16_t + initial_value: "0" + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # Writable + readable register backed by a global. The read publishes what it + # returns so the test can confirm the write half ran before the read half. + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(rw_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(rw_write_1).publish_state(x); + return true; + # Read-only register, read together with 0x01 by the first request's 2-register read. + - address: 0x02 + value_type: U_WORD + read_lambda: |- + id(rw_read_2).publish_state(0x00AA); + return 0x00AA; + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(basic_read).publish_state(1); + return 1; + - address: 0x05 + value_type: U_WORD + read_lambda: |- + id(read_after_peer_response).publish_state(1); + return 1; + # Second writable + readable register, targeted by the second FC 0x17 request. + - address: 0x06 + value_type: U_WORD + read_lambda: |- + id(rw_read_3).publish_state(id(stored_3)); + return id(stored_3); + write_lambda: |- + id(stored_3) = x; + id(rw_write_3).publish_state(x); + return true; + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(read_after_peer_timeout).publish_state(1); + return 1; + - address: 0x0B + value_type: U_WORD + read_lambda: |- + id(burst_read_a).publish_state(1); + return 0xA1; + - address: 0x0C + value_type: U_WORD + read_lambda: |- + id(burst_read_b).publish_state(1); + return 0xB2; + - address: 0x0D + value_type: U_WORD + read_lambda: |- + id(burst_read_before_peer).publish_state(1); + return 0xC3; + - address: 0x0E + value_type: U_WORD + read_lambda: return 0xD4; + +sensor: + - platform: template + name: "basic_read" + id: basic_read + - platform: template + name: "read_after_peer_response" + id: read_after_peer_response + - platform: template + name: "read_after_peer_timeout" + id: read_after_peer_timeout + - platform: template + name: "rw_write_1" + id: rw_write_1 + - platform: template + name: "rw_read_1" + id: rw_read_1 + - platform: template + name: "rw_read_2" + id: rw_read_2 + - platform: template + name: "rw_write_3" + id: rw_write_3 + - platform: template + name: "rw_read_3" + id: rw_read_3 + - platform: template + name: "burst_read_a" + id: burst_read_a + - platform: template + name: "burst_read_b" + id: burst_read_b + - platform: template + name: "burst_read_before_peer" + id: burst_read_before_peer + - platform: template + name: "burst_tx_a" + id: burst_tx_a + - platform: template + name: "burst_tx_b" + id: burst_tx_b + - platform: template + name: "burst_tx_before_peer" + id: burst_tx_before_peer + - platform: template + name: "burst_tx_probe" + id: burst_tx_probe + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml deleted file mode 100644 index e998861c2d3..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml +++ /dev/null @@ -1,106 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-rw - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_dev - baud_rate: 9600 - rx_full_threshold: 120 - rx_timeout: 2 - auto_start: false - debug: - injections: - # FC 0x17 Read/Write Multiple Registers on device 1: - # write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2). - # Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must - # read back the just-written 0x1234 in the same request. - - delay: 100ms - inject_rx: - [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] - # FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) - - # a write and read targeting a different register block. - - delay: 100ms - inject_rx: - [0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10] - -globals: - - id: stored_1 - type: uint16_t - initial_value: "0" - - id: stored_3 - type: uint16_t - initial_value: "0" - -modbus: - uart_id: virtual_uart_dev - role: server - -modbus_server: - - address: 1 - registers: - # Writable + readable register backed by a global. The read publishes what it - # returns so the test can confirm the write half ran before the read half. - - address: 0x01 - value_type: U_WORD - read_lambda: |- - id(rw_read_1).publish_state(id(stored_1)); - return id(stored_1); - write_lambda: |- - id(stored_1) = x; - id(rw_write_1).publish_state(x); - return true; - # Read-only register, read together with 0x01 by the first request's 2-register read. - - address: 0x02 - value_type: U_WORD - read_lambda: |- - id(rw_read_2).publish_state(0x00AA); - return 0x00AA; - # Second writable + readable register, targeted by the second request. - - address: 0x03 - value_type: U_WORD - read_lambda: |- - id(rw_read_3).publish_state(id(stored_3)); - return id(stored_3); - write_lambda: |- - id(stored_3) = x; - id(rw_write_3).publish_state(x); - return true; - -sensor: - - platform: template - name: "rw_write_1" - id: rw_write_1 - - platform: template - name: "rw_read_1" - id: rw_read_1 - - platform: template - name: "rw_read_2" - id: rw_read_2 - - platform: template - name: "rw_write_3" - id: rw_write_3 - - platform: template - name: "rw_read_3" - id: rw_read_3 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml new file mode 100644 index 00000000000..a70ed25bd7f --- /dev/null +++ b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml @@ -0,0 +1,16 @@ +esphome: + name: wh-template-unknown-test +host: +api: +logger: + +water_heater: + - platform: template + id: unknown_boiler + name: Unknown Boiler + # Both temperatures stay unknown, as they do before an upstream component reports a value. + current_temperature: !lambda "return NAN;" + target_temperature: !lambda "return NAN;" + supported_modes: + - "off" + - eco diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a010..b160f307628 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -1,7 +1,7 @@ """Helpers for manipulating the host platform's preferences file. ESPHome's host platform stores preferences in -``~/.esphome/prefs/.prefs`` using a simple binary layout that +``$ESPHOME_PREFDIR/.prefs`` using a simple binary layout that mirrors ``HostPreferences::sync()``: ``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry. @@ -11,13 +11,27 @@ boot (e.g. forcing safe mode) or to clear stale state between runs. from __future__ import annotations +import os from pathlib import Path import struct +_ENTRY = struct.Struct(" Path: - """Return the on-disk prefs file path for a host-platform device.""" - return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs" + """Return the on-disk prefs file path for a host-platform device. + + Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture + sets; refusing the ~/.esphome/prefs fallback keeps tests off real user + data if the fixture is ever bypassed.""" + prefdir = os.environ.get("ESPHOME_PREFDIR") + if not prefdir: + raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir") + return Path(prefdir) / f"{device_name}.prefs" def clear_host_prefs(device_name: str) -> None: @@ -34,16 +48,33 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: for key, data in entries.items(): if len(data) > 255: raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: - """Write a single preference entry, replacing the file's contents. +def read_host_prefs(device_name: str) -> dict[int, bytes]: + """Read the preference entries of a host-platform device; empty when + the file does not exist.""" + path = host_prefs_path(device_name) + if not path.exists(): + return {} + payload = path.read_bytes() + entries: dict[int, bytes] = {} + pos = 0 + while pos < len(payload): + key, length = _ENTRY.unpack_from(payload, pos) + pos += _ENTRY.size + entries[key] = payload[pos : pos + length] + pos += length + return entries - Returns the path that was written. - """ - return write_host_prefs(device_name, {key: data}) + +def force_safe_mode(device_name: str) -> None: + """Make the next boot of a host-platform device enter safe mode; other + saved preferences are kept.""" + entries = read_host_prefs(device_name) + entries[_SAFE_MODE_RTC_KEY] = struct.pack(" None: + await self.send_raw(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()) + + async def send_raw(self, msg_type: int, payload: bytes) -> None: + """Send a frame with a hand built payload, for shapes protobuf will not serialize.""" loop = asyncio.get_running_loop() - await loop.sock_sendall( - self._sock, - encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()), - ) + await loop.sock_sendall(self._sock, encode_frame(msg_type, payload)) async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None: """Read until at least one frame of msg_type has been received.""" diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 9c0debbc5cf..8b8e30c8df7 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging from typing import TypeVar @@ -57,6 +57,58 @@ async def wait_for_state( return await asyncio.wait_for(future, timeout=timeout) +class StateWaiter: + """Route one state subscription to any number of predicate waits.""" + + def __init__(self) -> None: + self._waiters: list[ + tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]] + ] = [] + + def on_state(self, state: EntityState) -> None: + for predicate, future in self._waiters: + if future.done(): + continue + try: + matched = predicate(state) + except Exception as exc: # noqa: BLE001 the wait re-raises it, the callback must not die + future.set_exception(exc) + continue + if matched: + future.set_result(state) + + def expect( + self, + predicate: Callable[[EntityState], bool], + timeout: float = 5.0, + label: str | None = None, + ) -> Awaitable[EntityState]: + """Arm a wait for the next state matching ``predicate`` and return the awaitable for it. + + The wait is armed here, at call time, so it can be created before the action that produces + the state and awaited afterwards; states seen before this call never match. + """ + entry = (predicate, asyncio.get_running_loop().create_future()) + self._waiters.append(entry) + return self._wait(entry, timeout, label) + + async def _wait( + self, + entry: tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]], + timeout: float, + label: str | None, + ) -> EntityState: + try: + async with asyncio.timeout(timeout): + return await entry[1] + except TimeoutError: + raise TimeoutError( + f"no state matched {label or entry[0]} within {timeout}s" + ) from None + finally: + self._waiters.remove(entry) + + def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, diff --git a/tests/integration/test_api_decode_wire_types.py b/tests/integration/test_api_decode_wire_types.py new file mode 100644 index 00000000000..5c8444273a5 --- /dev/null +++ b/tests/integration/test_api_decode_wire_types.py @@ -0,0 +1,142 @@ +"""decode_field() must take fields that match their declared wire type, drop the ones that do +not, skip unknown fields, and handle two byte tags, varints and length prefixes.""" + +from __future__ import annotations + +from collections.abc import Callable +import struct + +from aioesphomeapi import ( + EntityState, + LightState, + NumberState, + SwitchState, + TextState, + api_pb2, +) +import pytest + +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest] +WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5 + + +def tag(field: int, wire_type: int) -> bytes: + return encode_varint((field << 3) | wire_type) + + +@pytest.mark.asyncio +async def test_api_decode_wire_types( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + unused_tcp_port: int, +) -> None: + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + RawApiClient(unused_tcp_port) as raw, + ): + entities, _ = await client.list_entities_services() + switch = require_entity(entities, "wire_switch") + light = require_entity(entities, "wire_light") + text = require_entity(entities, "wire_text") + number = require_entity(entities, "wire_number") + key = tag(1, WIRE_FIXED32) + struct.pack(" None: + if isinstance(state, SwitchState) and state.key == switch.key: + switch_states.append(state.state) + waiter.on_state(state) + + def switch_is(value: bool) -> Callable[[EntityState], bool]: + return lambda s: ( + isinstance(s, SwitchState) and s.key == switch.key and s.state is value + ) + + def number_is(value: float) -> Callable[[EntityState], bool]: + return lambda s: ( + isinstance(s, NumberState) and s.key == number.key and s.state == value + ) + + initial = InitialStateHelper(entities) + client.subscribe_states(initial.on_state_wrapper(on_state)) + await initial.wait_for_initial_states() + await raw.connect() + + # A well formed command: fixed32 key, varint state + await raw.send_raw(SWITCH_COMMAND, key + on) + await waiter.expect(switch_is(True)) + await raw.send_raw(SWITCH_COMMAND, key + off) + await waiter.expect(switch_is(False)) + + # The same field with the wrong wire type is dropped, and a varint key never matches an + # entity; each of these would turn the switch on if the payload were read as a varint + seen = len(switch_states) + await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x01\x01") + await raw.send_raw( + SWITCH_COMMAND, key + tag(2, WIRE_FIXED32) + b"\x01\x00\x00\x00" + ) + await raw.send_raw( + SWITCH_COMMAND, tag(1, WIRE_VARINT) + encode_varint(switch.key) + on + ) + # Ordered on the raw socket itself: this frame cannot be parsed before the bad ones, so + # the only switch state since the marker must be the one it produces + await raw.send_raw(SWITCH_COMMAND, key + on) + await waiter.expect(switch_is(True), label="switch on after wrong wire types") + assert switch_states[seen:] == [True] + await raw.send_raw(SWITCH_COMMAND, key + off) + await waiter.expect(switch_is(False)) + + # Truncated bodies stop the decode loop without taking the connection down: a tag with its + # continuation bit set and nothing after it, a length prefix past the end of the payload, + # and a fixed32 with two of its four bytes + seen = len(switch_states) + await raw.send_raw(SWITCH_COMMAND, key + b"\x80") + await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x7f" + b"ab") + await raw.send_raw(SWITCH_COMMAND, tag(1, WIRE_FIXED32) + b"\x01\x02") + await raw.send_raw(SWITCH_COMMAND, key + on) + await waiter.expect(switch_is(True), label="switch on after truncated frames") + assert switch_states[seen:] == [True] + await raw.send_raw(SWITCH_COMMAND, key + off) + await waiter.expect(switch_is(False)) + + # A negative number goes through the fixed32 float path of a normal client + client.number_command(number.key, -77.5) + await waiter.expect(number_is(-77.5)) + + # An unknown field ahead of the known ones is skipped; field 200 needs a two byte tag + await raw.send_raw( + SWITCH_COMMAND, tag(200, WIRE_VARINT) + encode_varint(300) + key + on + ) + await waiter.expect(switch_is(True)) + + # Two byte tags (effect fields 18 and 19) and a two byte varint (300 ms transition) + client.light_command( + light.key, state=True, brightness=0.5, transition_length=0.3, effect="Pulse" + ) + await waiter.expect( + lambda s: ( + isinstance(s, LightState) and s.key == light.key and s.effect == "Pulse" + ) + ) + client.light_command(light.key, effect="None", state=False) + await waiter.expect( + lambda s: isinstance(s, LightState) and s.key == light.key and not s.state + ) + + # A string whose length prefix needs two varint bytes + long_text = "w" * 200 + client.text_command(text.key, long_text) + await waiter.expect( + lambda s: ( + isinstance(s, TextState) and s.key == text.key and s.state == long_text + ) + ) diff --git a/tests/integration/test_api_empty_message_roundtrip.py b/tests/integration/test_api_empty_message_roundtrip.py new file mode 100644 index 00000000000..3cd5b6cd9e4 --- /dev/null +++ b/tests/integration/test_api_empty_message_roundtrip.py @@ -0,0 +1,37 @@ +"""Messages without fields go through the shared ProtoMessage entry points on both directions.""" + +from __future__ import annotations + +from aioesphomeapi import api_pb2 +import pytest + +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient +from .types import RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_empty_message_roundtrip( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client: + await client.connect() + + # Field free request and reply on the plain send path + await client.send_message(api_pb2.PingRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.PingResponse]) + + # Field free request answered by a message with fields, and a list that ends with + # the field free ListEntitiesDoneResponse through the batching path + await client.send_message(api_pb2.DeviceInfoRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DeviceInfoResponse]) + await client.send_message(api_pb2.ListEntitiesRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]) + assert ( + client.frame_counts[MESSAGE_TYPE_OF[api_pb2.ListEntitiesSwitchResponse]] + == 1 + ) + + await client.send_message(api_pb2.DisconnectRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DisconnectResponse]) diff --git a/tests/integration/test_api_encode_boundaries.py b/tests/integration/test_api_encode_boundaries.py new file mode 100644 index 00000000000..765dccbf4ef --- /dev/null +++ b/tests/integration/test_api_encode_boundaries.py @@ -0,0 +1,76 @@ +"""Encode paths at their branch boundaries: zero skipped float, fixed32 state, negative int32, +length prefixes of two varint bytes and two byte field tags.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + NumberState, + SelectInfo, + SensorInfo, + SensorState, + TextSensorState, +) +import pytest + +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +LONG_OPTION = ( + "option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-" + "when-the-list-entities-response-is-encoded-xxxxxxxxxx" +) + + +@pytest.mark.asyncio +async def test_api_encode_boundaries( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + device_info, (entities, _) = await asyncio.gather( + client.device_info(), client.list_entities_services() + ) + assert device_info.suggested_area == "Kitchen" + + sensor = require_entity(entities, "zero_then_value", SensorInfo) + assert sensor.accuracy_decimals == -2 + select = require_entity(entities, "long_option_select", SelectInfo) + assert len(LONG_OPTION) >= 128 + assert select.options == ["short", LONG_OPTION] + text = require_entity(entities, "long_text") + number = require_entity(entities, "negative_number") + button = require_entity(entities, "publish_values") + + initial = InitialStateHelper(entities) + waiter = StateWaiter() + client.subscribe_states(initial.on_state_wrapper(waiter.on_state)) + await initial.wait_for_initial_states() + + # A float of exactly zero is skipped on the wire and must still read as 0.0, not missing + first = initial.initial_states[sensor.key] + assert isinstance(first, SensorState) + assert first.state == 0.0 and not first.missing_state + first_number = initial.initial_states[number.key] + assert isinstance(first_number, NumberState) + assert first_number.state == -123.5 + + # Arm both waits before the press so no ordering of the replies can slip past them + sensor_seen = waiter.expect( + lambda s: ( + isinstance(s, SensorState) and s.key == sensor.key and s.state == 12.5 + ), + label="sensor 12.5", + ) + text_seen = waiter.expect( + lambda s: ( + isinstance(s, TextSensorState) + and s.key == text.key + and s.state == "y" * 200 + ), + label="text 200 x y", + ) + client.button_command(button.key) + await asyncio.gather(sensor_seen, text_seen) diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py index f315335d1bc..d103167a00c 100644 --- a/tests/integration/test_api_zero_psk_provisioning.py +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -24,7 +24,6 @@ from .types import ( RunCompiledFunction, ) -pytestmark = pytest.mark.usefixtures("isolated_preferences") NEW_KEY = PROVISIONING_PSK diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index 9c7395371bb..ec9a94bf4f0 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for cover ControlAction and CoverPublishAction. +"""Integration test for the cover.control and cover.template.publish actions. Tests that cover.control and cover.template.publish automation actions work correctly with the single stateless apply lambda/function pointer @@ -22,7 +22,7 @@ async def test_cover_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test cover ControlAction/CoverPublishAction with constants and lambdas.""" + """Test cover.control and cover.template.publish with constants and lambdas.""" loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: cover_state_future: asyncio.Future[CoverState] | None = None diff --git a/tests/integration/test_entity_missing_state.py b/tests/integration/test_entity_missing_state.py new file mode 100644 index 00000000000..f87ee213462 --- /dev/null +++ b/tests/integration/test_entity_missing_state.py @@ -0,0 +1,132 @@ +"""Integration test for missing_state on switch, climate and water heater. + +These three entity types always sent a concrete state, so a client could not +tell a real OFF from an entity whose value has not been read yet. They now +report missing_state until something publishes, like every other stateful +entity does. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + ClimateInfo, + ClimateMode, + ClimateState, + EntityInfo, + EntityState, + SwitchInfo, + SwitchState, + WaterHeaterInfo, + WaterHeaterMode, + WaterHeaterState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_entity_missing_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a switch, climate and water heater start out unknown. + + This verifies that: + 1. They report missing_state=True while nothing has published + 2. Publishing clears missing_state and reports the published value + 3. A first value that happens to equal the default still publishes + """ + loop = asyncio.get_running_loop() + futures: dict[int, asyncio.Future[EntityState]] = {} + + def on_state(state: EntityState) -> None: + """Resolve the pending future for the entity that changed.""" + future = futures.get(state.key) + if future is not None and not future.done(): + future.set_result(state) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + switch_info = require_entity(entities, "test_switch", SwitchInfo) + climate_info = require_entity(entities, "test_climate", ClimateInfo) + initial_climate_info = require_entity(entities, "initial_climate", ClimateInfo) + water_heater_info = require_entity( + entities, "test_water_heater", WaterHeaterInfo + ) + lambda_water_heater_info = require_entity( + entities, "lambda_water_heater", WaterHeaterInfo + ) + publish_button = require_entity( + entities, "publish_states", description="Publish States button" + ) + stateful: list[EntityInfo] = [ + switch_info, + climate_info, + water_heater_info, + lambda_water_heater_info, + ] + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Nothing has published yet, so all of them must report unknown + for info in stateful: + state = initial_state_helper.initial_states.get(info.key) + assert state is not None, f"No initial state received for {info.object_id}" + assert state.missing_state is True, ( + f"Initial state for {info.object_id} should have " + f"missing_state=True, got {state}" + ) + + # A configured initial_state must not start out unknown + initial_climate_state = initial_state_helper.initial_states.get( + initial_climate_info.key + ) + assert isinstance(initial_climate_state, ClimateState) + assert initial_climate_state.missing_state is False + assert initial_climate_state.mode is ClimateMode.HEAT + + # Publishing a state on each one clears missing_state + futures = {info.key: loop.create_future() for info in stateful} + client.button_command(publish_button.key) + + try: + await asyncio.wait_for(asyncio.gather(*futures.values()), timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for published states") + + switch_state = futures[switch_info.key].result() + assert isinstance(switch_state, SwitchState) + assert switch_state.missing_state is False + assert switch_state.state is True + + climate_state = futures[climate_info.key].result() + assert isinstance(climate_state, ClimateState) + assert climate_state.missing_state is False + assert climate_state.mode is ClimateMode.HEAT + + water_heater_state = futures[water_heater_info.key].result() + assert isinstance(water_heater_state, WaterHeaterState) + assert water_heater_state.missing_state is False + assert water_heater_state.mode is WaterHeaterMode.ECO + + # Its first value is OFF, which is also the default the entity starts on, + # so it only leaves unknown if the first value publishes regardless + lambda_state = futures[lambda_water_heater_info.key].result() + assert isinstance(lambda_state, WaterHeaterState) + assert lambda_state.missing_state is False + assert lambda_state.mode is WaterHeaterMode.OFF diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index f8c122c6e1c..56a685eac3a 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -29,6 +29,7 @@ from .const import ( PROVISIONING_PSK, ZERO_PSK, ) +from .host_prefs import force_safe_mode from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter DEVICE_NAME = "host-ota-test" @@ -166,6 +167,42 @@ class _Device: assert self.proc.returncode is None, "process died on rejected OTA" +def _handshake_then_close(port: int, noise_psk: str) -> None: + """Negotiate and complete the Noise handshake like a key probe, then + hang up without sending an OTA type.""" + with socket.create_connection((LOCALHOST, port), timeout=5.0) as sock: + espota2.send_check(sock, espota2.MAGIC_BYTES, "magic bytes") + _, version = espota2.receive_exactly(sock, 2, "version", espota2.RESPONSE_OK) + features_to_send = ( + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_NOISE + ) + espota2.send_check(sock, features_to_send, "features") + espota2.receive_exactly(sock, 1, "features", espota2.RESPONSE_FEATURE_FLAGS) + (features,) = espota2.receive_exactly(sock, 1, "feature flags", None) + assert features & espota2.SERVER_FEATURE_SUPPORTS_NOISE + prologue = ( + espota2.NOISE_PROLOGUE_INIT + + bytes(espota2.MAGIC_BYTES) + + bytes([espota2.RESPONSE_OK, version, features_to_send]) + + bytes([espota2.RESPONSE_FEATURE_FLAGS, features]) + ) + noise = espota2.NoiseSocketWrapper(sock, noise_psk, prologue) + noise.do_handshake() + espota2.receive_exactly(noise, 1, "auth", espota2.RESPONSE_AUTH_OK) + + +async def _provision_key( + dev: _Device, api_client_connected: APIClientConnectedFactory +) -> None: + """Provision PROVISIONING_PSK over the api and wait for it to activate.""" + async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client: + assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + @pytest.mark.asyncio async def test_host_ota_self_update( yaml_config: str, @@ -211,22 +248,55 @@ async def test_host_ota_encrypted( compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], ) -> None: - """Encrypted self-OTA succeeds; a plaintext upload to the same device fails.""" + """A client that leaves right after the handshake, as a key probe does, + is a clean close, not an OTA error; a plaintext upload is refused; an + encrypted self-OTA succeeds.""" pytest.importorskip("aioesphomeapi.noise") dev = _Device( *await _build( yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port ) ) - async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): dev.proc = proc await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await asyncio.get_running_loop().run_in_executor( + None, _handshake_then_close, dev.ota_port, API_KEY + ) + # The error path logs its warning instead of this line, never after it + await _wait_for_line(lines, "Client left after the handshake") + assert not [line for line in lines if "[W][esphome.ota" in line] await dev.refused_ota( None, None, "plaintext upload to an encrypted device must fail" ) await dev.ota(None, API_KEY, "encrypted OTA reported failure") +@pytest.mark.asyncio +async def test_host_ota_encrypted_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Safe mode never constructs the api server, so an encrypted OTA with the + api key has to run on the ota component's own copy of that key.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + # The api port never opens in safe mode, so wait for the log line instead + force_safe_mode(DEVICE_NAME) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + # The safe mode boot clears the counter, so the re-exec boots normally + await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure") + + @pytest.mark.asyncio async def test_host_ota_api_key_offer_with_password( yaml_config: str, @@ -305,11 +375,7 @@ async def test_host_ota_provisioned_api_key( None, None, "plaintext upload to an unprovisioned device must succeed" ) - async with api_client_connected( - port=dev.api_port, noise_psk=ZERO_PSK - ) as client: - assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True - await asyncio.sleep(KEY_ACTIVATION_DELAY) + await _provision_key(dev, api_client_connected) key = PROVISIONING_PSK.decode() await dev.ota( @@ -319,6 +385,45 @@ async def test_host_ota_provisioned_api_key( await dev.ota(None, None, "plaintext must stay accepted on an offering device") +@pytest.mark.asyncio +async def test_host_ota_provisioned_api_key_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + api_client_connected: APIClientConnectedFactory, +) -> None: + """Safe mode never constructs the api server, so the OTA has to load the + provisioned key from preferences itself to keep encrypting there.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _provision_key(dev, api_client_connected) + + # The saved key is already on disk; a host reboot outside an OTA just + # exits, so safe mode takes a second start + force_safe_mode(DEVICE_NAME) + key = PROVISIONING_PSK.decode() + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + await dev.ota( + None, + key, + "encrypted upload with the provisioned key must succeed in safe mode", + ) + # The re-exec boots normally and the api reads the same record + async with api_client_connected(port=dev.api_port, noise_psk=key): + pass + + @pytest.mark.asyncio async def test_host_ota_rejects_garbage( yaml_config: str, diff --git a/tests/integration/test_host_preferences_suspend_resume.py b/tests/integration/test_host_preferences_suspend_resume.py index ab08d5c4409..5f08d5519ed 100644 --- a/tests/integration/test_host_preferences_suspend_resume.py +++ b/tests/integration/test_host_preferences_suspend_resume.py @@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None: await asyncio.sleep(0.05) -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path: - """Keep host preferences per-test so this test never touches the real - ~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR.""" - prefdir = tmp_path / "prefs" - monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir)) - return prefdir / f"{DEVICE_NAME}.prefs" - - @pytest.mark.asyncio async def test_host_preferences_suspend_resume( yaml_config: str, @@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume( isolated_preferences: Path, ) -> None: """Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing.""" - pref_file = isolated_preferences + pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs" loop = asyncio.get_running_loop() saved_in_memory = loop.create_future() diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py index 9a5c16a04d4..bb690645bd4 100644 --- a/tests/integration/test_light_control_action.py +++ b/tests/integration/test_light_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for LightControlAction. +"""Integration test for the light control apply actions. Tests that light.turn_on, light.turn_off, and light.control automation actions work correctly with the compact per-field union storage. Exercises both constant @@ -19,7 +19,7 @@ async def test_light_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test LightControlAction with constants and lambdas.""" + """Test light.turn_on, light.turn_off and light.control with constants and lambdas.""" async with run_compiled(yaml_config), api_client_connected() as client: state_futures: dict[int, asyncio.Future[Any]] = {} diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index 657e273fe73..12d17bdee6c 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left - behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs, - keyed only by device name).""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) - - @pytest.mark.asyncio async def test_light_initial_state( yaml_config: str, @@ -51,3 +43,21 @@ async def test_light_initial_state( restore_and_on_state = helper.initial_states[restore_and_on_light.key] assert restore_and_on_state.state is True assert restore_and_on_state.brightness == pytest.approx(1.0) + + # With neither restore_mode nor restore_state configured, initial_state: must + # be honored as-is -- unlike every explicit restore_mode: value, omitting both + # keys entirely must not force the light off. + no_restore_key_light = require_entity(entities, "test_no_restore_key_light") + no_restore_key_state = helper.initial_states[no_restore_key_light.key] + assert no_restore_key_state.state is True + assert no_restore_key_state.brightness == pytest.approx(0.6, abs=0.01) + + # color_mode omitted: inferred from the colour fields, so they are applied (the + # light comes up red rather than the default white). RGB values are normalised to + # proportions, so 30% red on its own is pure red. + inferred_light = require_entity(entities, "test_inferred_color_mode_light") + inferred_state = helper.initial_states[inferred_light.key] + assert inferred_state.state is True + assert inferred_state.red == pytest.approx(1.0, abs=0.01) + assert inferred_state.green == pytest.approx(0.0, abs=0.01) + assert inferred_state.blue == pytest.approx(0.0, abs=0.01) diff --git a/tests/integration/test_light_restore_state.py b/tests/integration/test_light_restore_state.py new file mode 100644 index 00000000000..57727938643 --- /dev/null +++ b/tests/integration/test_light_restore_state.py @@ -0,0 +1,116 @@ +"""Integration test for the light `restore_state:` key. + +Tests that: +1. On first boot (nothing saved yet), lights come up at their hardware default (off) -- + restore_state overrides never apply before anything is ever saved. +2. After a state is saved and the device restarts, a `restore_state: {}` light comes + back exactly as saved, while a light with explicit overrides applies those overrides + on top of the loaded state regardless of what was saved. +""" + +from __future__ import annotations + +import asyncio +import socket +from typing import Any + +from aioesphomeapi import LightInfo, LightState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-light-restore-state-test" + + +@pytest.mark.asyncio +async def test_light_restore_state( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test restore_state: {} vs restore_state: with overrides, across a restart.""" + port, port_socket = reserved_tcp_port + + clear_host_prefs(DEVICE_NAME) + + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + port_socket.close() + + # --- First run: nothing saved yet, both lights must come up off --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + keep_light = require_entity(entities, "test_light_keep", LightInfo) + override_light = require_entity(entities, "test_light_override", LightInfo) + + # A single subscription serves both initial-state capture and later + # state-change waits -- a second subscribe_states call would restart the + # device's initial-state iterator and could resolve a wait on replayed data. + loop = asyncio.get_running_loop() + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + keep_initial = helper.initial_states[keep_light.key] + assert isinstance(keep_initial, LightState) + assert keep_initial.state is False + + override_initial = helper.initial_states[override_light.key] + assert isinstance(override_initial, LightState) + assert override_initial.state is False + + # Set both lights to a known, saved state + async def wait_for_state(key: int, timeout: float = 2.0) -> Any: + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + client.light_command(key=keep_light.key, state=True, brightness=0.64) + await wait_for_state(keep_light.key) + + client.light_command(key=override_light.key, state=True, brightness=0.3) + await wait_for_state(override_light.key) + + # --- Second run: same binary, same prefs file --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + keep_light = require_entity(entities, "test_light_keep", LightInfo) + override_light = require_entity(entities, "test_light_override", LightInfo) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda s: None)) + await helper.wait_for_initial_states() + + # restore_state: {} -- comes back exactly as saved + keep_state = helper.initial_states[keep_light.key] + assert isinstance(keep_state, LightState) + assert keep_state.state is True + assert keep_state.brightness == pytest.approx(0.64, abs=0.01) + + # restore_state: with overrides -- state inverted, brightness forced to 100% + # regardless of what was actually saved (0.3) + override_state = helper.initial_states[override_light.key] + assert isinstance(override_state, LightState) + assert override_state.state is False + assert override_state.brightness == pytest.approx(1.0, abs=0.01) + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_light_resume_effect.py b/tests/integration/test_light_resume_effect.py new file mode 100644 index 00000000000..dce5feb9ab5 --- /dev/null +++ b/tests/integration/test_light_resume_effect.py @@ -0,0 +1,147 @@ +"""Integration test verifying light effect restore on/off cycles. + +Tests that when resume_effect is enabled, the previously active effect +is restored when the light is turned back on without explicit effect/color +parameters. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import ColorMode, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_resume_effect( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """When resume_effect is enabled, effect persists after off/on cycle.""" + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light_resume = next(e for e in entities if e.object_id == "test_light_resume") + light_no_resume = next( + e for e in entities if e.object_id == "test_light_no_resume" + ) + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # Drain initial state burst + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait( + light: LightInfo, timeout: float = 5.0, **kwargs: Any + ) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = asyncio.get_running_loop().create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # Test 1: Start with Pulse effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + # Test 2: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 3: Turn on without specifying effect — should restore Pulse Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Pulse Effect", ( + "Effect should be restored when turning on without explicit effect parameter" + ) + + # Test 4: Switch to Strobe effect + state = await send_and_wait(light_resume, effect="Strobe Effect") + assert state.effect == "Strobe Effect" + + # Test 5: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 6: Turn on without effect — should restore Strobe Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Strobe Effect", ( + "Effect should be restored to Strobe after turning on" + ) + + # Test 7: Explicitly set effect to None while turning on — should not restore + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_resume, state=True, effect="None") + assert state.state is True + assert state.effect == "None", ( + "Explicit effect=None should override restoration" + ) + + # Test 8: Turn off, then on with explicit effect — should use explicit effect + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.effect == "Pulse Effect" + + # Test 9: a turn-on that asks for something specific does not restore, and the + # effect it replaced must not come back on a later plain off/on + state = await send_and_wait(light_resume, state=False) + assert state.state is False + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None", "A turn-on with brightness should not restore" + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "An effect dropped on an earlier cycle must not return" + ) + + # Test 10: a plain turn-on sent to a light that is already on never starts the + # remembered effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None" + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "A lit light must not pick up the remembered effect" + ) + + # Test 11: a turn-on that names a color mode is not plain, so it does not restore + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait( + light_resume, state=True, color_mode=ColorMode.BRIGHTNESS + ) + assert state.effect == "None", "A turn-on with a color mode should not restore" + + # Test 12: Turn on effect, then off, then on without effect — should not restore Pulse Effect + state = await send_and_wait(light_no_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + state = await send_and_wait(light_no_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_no_resume, state=True) + assert state.state is True + assert state.effect == "None", ( + "Effect should NOT be restored when resume_effect=false" + ) diff --git a/tests/integration/test_light_transition_state_publish_interval.py b/tests/integration/test_light_transition_state_publish_interval.py new file mode 100644 index 00000000000..3b37854e761 --- /dev/null +++ b/tests/integration/test_light_transition_state_publish_interval.py @@ -0,0 +1,328 @@ +"""Integration tests for the light transition_state_publish_interval option.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from itertools import pairwise + +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + LightInfo, + LightState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +Timeline = list[tuple[float, LightState]] +DonePredicate = Callable[[float, LightState], bool] + + +class _Recorder: + """Records the states one light publishes while an action runs. + + ``run`` fires ``action`` and returns the (elapsed, state) timeline once a + published state satisfies ``done``, after ``settle`` more seconds so late + publishes still land in the timeline. + """ + + def __init__(self) -> None: + self._loop = asyncio.get_running_loop() + self._event = asyncio.Event() + self._key = 0 + self._start = 0.0 + self._done: DonePredicate | None = None + self.timeline: Timeline = [] + + def on_state(self, state: EntityState) -> None: + if ( + self._done is None + or not isinstance(state, LightState) + or state.key != self._key + ): + return + elapsed = self._loop.time() - self._start + self.timeline.append((elapsed, state)) + if self._done(elapsed, state): + self._event.set() + + async def run( + self, + key: int, + action: Callable[[], None], + done: DonePredicate, + settle: float = 0.0, + ) -> Timeline: + self.timeline = [] + self._key = key + self._done = done + self._event.clear() + self._start = self._loop.time() + action() + async with asyncio.timeout(5): + await self._event.wait() + if settle: + await asyncio.sleep(settle) + self._done = None + return self.timeline + + +async def _subscribe(client: APIClient) -> tuple[list[EntityInfo], _Recorder]: + """List entities and attach a recorder once the initial states have arrived.""" + entities, _ = await client.list_entities_services() + helper = InitialStateHelper(entities) + recorder = _Recorder() + client.subscribe_states(helper.on_state_wrapper(recorder.on_state)) + await helper.wait_for_initial_states() + return entities, recorder + + +def _visible_brightness(state: LightState) -> float: + """Brightness as a remote sees it: an off light counts as zero.""" + return state.brightness if state.state else 0.0 + + +def _brightness_is(value: float) -> DonePredicate: + return lambda _elapsed, state: ( + _visible_brightness(state) == pytest.approx(value, abs=0.01) + ) + + +def _brightness_values(timeline: Timeline) -> list[float]: + return [_visible_brightness(state) for _, state in timeline] + + +def _assert_ramp(timeline: Timeline, target: float) -> None: + """Several states were published and the last one is ``target`` after ~1 s.""" + values = _brightness_values(timeline) + assert len(values) >= 3, values + assert values[-1] == pytest.approx(target, abs=0.05), values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_light_without_interval_publishes_target_once( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A light without the option publishes the target once, up front.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + legacy = require_entity(entities, "test_legacy_light", LightInfo) + + timeline = await recorder.run( + legacy.key, + lambda: client.light_command( + key=legacy.key, state=True, brightness=0.8, transition_length=1.0 + ), + _brightness_is(0.8), + settle=1.3, + ) + + assert _brightness_values(timeline) == [pytest.approx(0.8)] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_transition_interval_nonzero_emits_intermediate_updates( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Turning on over 1 s with a 200 ms interval publishes a rising ramp.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + + timeline = await recorder.run( + mono.key, + lambda: client.light_command( + key=mono.key, state=True, brightness=1.0, transition_length=1.0 + ), + _brightness_is(1.0), + ) + + values = _brightness_values(timeline) + assert len(values) >= 5, values + assert values[0] == pytest.approx(0.0, abs=0.1), values + assert values[-1] == pytest.approx(1.0, abs=0.05), values + assert len([v for v in values if 0.1 < v < 0.9]) >= 2, values + assert all(b >= a - 0.1 for a, b in pairwise(values)), values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_light_transition_state_publish_interval( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Default-length, RGB and color temperature transitions publish on the interval.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + rgb = require_entity(entities, "test_rgb_light", LightInfo) + cwww = require_entity(entities, "test_cwww_light", LightInfo) + + # No transition_length: default_transition_length (1 s) applies + timeline = await recorder.run( + mono.key, + lambda: client.light_command(key=mono.key, state=True, brightness=1.0), + _brightness_is(1.0), + ) + _assert_ramp(timeline, 1.0) + + timeline = await recorder.run( + rgb.key, + lambda: client.light_command( + key=rgb.key, + state=True, + brightness=1.0, + rgb=(1.0, 0.0, 0.0), + transition_length=1.0, + ), + _brightness_is(1.0), + ) + _assert_ramp(timeline, 1.0) + + # Start at the cold end instantly so the fade to 300 mireds has a gradient + await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, + state=True, + brightness=1.0, + color_temperature=153.0, + transition_length=0.0, + ), + lambda _t, s: ( + s.state and s.color_temperature == pytest.approx(153.0, abs=1.0) + ), + ) + timeline = await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, + state=True, + brightness=1.0, + color_temperature=300.0, + transition_length=1.0, + ), + lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0), + ) + ct_values = [state.color_temperature for _, state in timeline] + assert len(ct_values) >= 3, ct_values + assert min(ct_values) < max(ct_values), ct_values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_flash_interval_emits_intermediate_updates( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A flash publishes its value on the interval and ends back where it started.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + + await recorder.run( + mono.key, + lambda: client.light_command( + key=mono.key, state=True, brightness=0.4, transition_length=0.0 + ), + _brightness_is(0.4), + ) + timeline = await recorder.run( + mono.key, + lambda: client.light_command( + key=mono.key, brightness=1.0, flash_length=1.0 + ), + lambda t, s: ( + t > 0.5 and _visible_brightness(s) == pytest.approx(0.4, abs=0.01) + ), + ) + + values = _brightness_values(timeline) + assert values.count(pytest.approx(1.0, abs=0.01)) >= 3, values + assert values[-1] == pytest.approx(0.4, abs=0.05), values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_interval_save") +async def test_transition_interval_persistence_semantics( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A save=true interval transition saves its target, not a sample, and restores it.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + button = require_entity(entities, "run_persistence_transition", ButtonInfo) + + timeline = await recorder.run( + mono.key, + lambda: client.button_command(button.key), + _brightness_is(1.0), + ) + _assert_ramp(timeline, 1.0) + + # The restored light fades up from off over its default transition, so wait for the end + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + mono = require_entity(entities, "test_mono_light", LightInfo) + await wait_for_state( + client, + lambda s: ( + isinstance(s, LightState) + and s.key == mono.key + and _visible_brightness(s) == pytest.approx(1.0, abs=0.01) + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_partial_call_during_interval_transition_keeps_target( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A colour temperature change mid-fade keeps the fade's brightness target.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + cwww = require_entity(entities, "test_cwww_light", LightInfo) + + # Let the fade from off run for a couple of samples before changing only the colour + await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, + state=True, + brightness=1.0, + color_temperature=153.0, + transition_length=1.0, + ), + lambda t, _s: t >= 0.3, + ) + timeline = await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, color_temperature=300.0, transition_length=1.0 + ), + lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0), + ) + + assert _brightness_values(timeline)[-1] == pytest.approx(1.0, abs=0.01) diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 652877fc092..55756fca13d 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -25,19 +25,13 @@ from __future__ import annotations import asyncio import re -import struct import pytest from .conftest import run_binary -from .host_prefs import clear_host_prefs, write_host_pref +from .host_prefs import clear_host_prefs, force_safe_mode from .types import CompileFunction, ConfigWriter -# Must match esphome::safe_mode::RTC_KEY in safe_mode.h -SAFE_MODE_RTC_KEY = 233825507 -# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC -ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE - DEVICE_NAME = "safe-mode-loop-runs" THREAD_LOG_MARKER = "looping component ran in safe mode" @@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs( # Compile finished successfully; pre-populate prefs so the *next* run # enters safe mode immediately. - write_host_pref( - DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" tuple[float, float]: + button = next( + (e for e in entities if button_object_id in e.object_id.lower()), None + ) + assert button is not None, f"{button_object_id} not found" + client.button_command(button.key) + try: + return await asyncio.wait_for(future, timeout=5.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {label} log message") + + +@pytest.mark.asyncio +async def test_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With filters compiled in, raw state is stored separately from state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + if not with_filter_future.done() and ( + match := WITH_FILTER_PATTERN.search(line) + ): + with_filter_future.set_result( + (float(match.group(1)), float(match.group(2))) + ) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 + + state, raw_state = await _press_and_read( + client, + entities, + "test_with_filter_button", + with_filter_future, + "WITH_FILTER", + ) + assert state == 43.0 + assert raw_state == 21.5 + + +@pytest.mark.asyncio +async def test_sensor_raw_state_no_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Without filters compiled in, get_raw_state() returns state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 diff --git a/tests/integration/test_set_internal_at_boot.py b/tests/integration/test_set_internal_at_boot.py new file mode 100644 index 00000000000..68b0bd10802 --- /dev/null +++ b/tests/integration/test_set_internal_at_boot.py @@ -0,0 +1,41 @@ +"""Integration test for set_internal() called during and after setup.""" + +from __future__ import annotations + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_set_internal_at_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """set_internal() in on_boot changes API exposure, later calls log an error.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + names = {entity.name for entity in entities} + + assert "Hidden At Boot" not in names + assert "Shown At Boot" in names + assert "Untouched" in names + + late = next(s for s in services if s.name == "set_internal_late") + await client.execute_service(late, {}) + await waiter.wait_for( + "'Untouched'", + "set_internal() after setup is undefined behavior", + timeout=5.0, + ) + + # Still written during the deprecation window, ignored from 2027.3.0 + entities, _ = await client.list_entities_services() + assert "Untouched" not in {entity.name for entity in entities} diff --git a/tests/integration/test_snapshot_display.py b/tests/integration/test_snapshot_display.py index 771cf0cf7d9..6f720ef30e3 100644 --- a/tests/integration/test_snapshot_display.py +++ b/tests/integration/test_snapshot_display.py @@ -3,19 +3,66 @@ from __future__ import annotations import asyncio +import io from pathlib import Path from aioesphomeapi import LogLevel +from PIL import Image, UnidentifiedImageError import pytest -from .bmp_utils import capture_when_drawn, wait_for_bmp +from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp from .types import APIClientConnectedFactory, RunCompiledFunction WIDTH = 101 HEIGHT = 64 +ANIMATION_FRAMES = 5 +# The fixture asks for 20 frames a second, and a GIF counts time in milliseconds here. +ANIMATION_FRAME_MS = 50 + +NOISE_WIDTH = 200 +NOISE_HEIGHT = 150 +NOISE_FRAMES = 3 + # Part of the message the writer logs when it will not write over a file that is already there. REFUSAL_MESSAGE = b"not overwriting" +# Part of the message logged when an animation is asked for while another is still being recorded. +BUSY_MESSAGE = b"Already recording" + + +async def wait_for_gif(path: Path, frames: int, timeout: float = 5.0) -> Image.Image: + """Wait for a complete animated GIF with the given number of frames and return it. + + The file exists from the moment the recording starts and grows a frame at a time, so keep + reading until it holds all of them and ends with the GIF trailer. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + data = path.read_bytes() + # Open the bytes just read, so the trailer check and the decode see the same file. + image = Image.open(io.BytesIO(data)) + if data.endswith(b";") and image.n_frames == frames: + # Decoding every frame proves the compressed data is all there and valid. + for frame in range(frames): + image.seek(frame) + image.load() + image.seek(0) + return image + except (FileNotFoundError, UnidentifiedImageError, OSError, EOFError): + pass + await asyncio.sleep(0.05) + raise AssertionError( + f"no complete {frames} frame GIF appeared at {path} within {timeout}s" + ) + + +def bmp_as_image(bmp: Bmp) -> Image.Image: + """The picture in a decoded BMP, as an RGB image.""" + return Image.frombytes( + "RGB", (bmp.width, bmp.height), bmp.pixels, "raw", "BGR", 0, -1 + ) @pytest.mark.asyncio @@ -39,12 +86,50 @@ async def test_snapshot_display( async def take(name: str) -> None: await client.execute_service(service, {"name": name}) + animation_service = next(s for s in services if s.name == "take_animation") + # The test card is drawn in several colours, so once it is on the screen the picture is # not one flat shade. Capturing until that is true waits out the first update rather than # racing it. image, capture = await capture_when_drawn(take, snapshot_dir) assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + # Asking for frames records a GIF: every frame is the size of the display and lasts as long + # as the frame rate says. The test card does not move, so every frame is the picture + # captured above. + await client.execute_service(animation_service, {"name": "movie"}) + movie = await wait_for_gif(snapshot_dir / "movie.gif", ANIMATION_FRAMES) + assert movie.size == (WIDTH, HEIGHT) + expected = bmp_as_image(image) + for frame in range(ANIMATION_FRAMES): + movie.seek(frame) + assert movie.info["duration"] == ANIMATION_FRAME_MS + assert movie.convert("RGB").tobytes() == expected.tobytes() + + # A picture of 256 colours in no pattern makes a long run of compression codes, so recording + # it passes the point where the code width grows and where the dictionary is started over. + # The picture uses no more than 256 colours, so it is stored exactly and every frame must + # come back identical to the one captured as a BMP. + noise_service = next(s for s in services if s.name == "take_noise_snapshot") + noise_animation = next(s for s in services if s.name == "take_noise_animation") + + async def take_noise(name: str) -> None: + await client.execute_service(noise_service, {"name": name}) + + noise, _ = await capture_when_drawn(take_noise, snapshot_dir, "noisedrawn") + assert (noise.width, noise.height) == (NOISE_WIDTH, NOISE_HEIGHT) + noise_expected = bmp_as_image(noise) + assert ( + len(noise_expected.getcolors(NOISE_WIDTH * NOISE_HEIGHT)) > 200 + ) # a busy picture + await client.execute_service(noise_animation, {"name": "noise"}) + noise_movie = await wait_for_gif( + snapshot_dir / "noise.gif", NOISE_FRAMES, timeout=15.0 + ) + for frame in range(NOISE_FRAMES): + noise_movie.seek(frame) + assert noise_movie.convert("RGB").tobytes() == noise_expected.tobytes() + # An extension is only added when there is not one already, whatever its case. await take("UPPER.BMP") await wait_for_bmp(snapshot_dir / "UPPER.BMP") @@ -54,18 +139,30 @@ async def test_snapshot_display( await take("../escape") await wait_for_bmp(snapshot_dir / ".._escape.bmp") - # A second capture under a name already used must fail rather than write over the first. - # Wait for the device to report the refusal: on its own, an unchanged file cannot tell a - # refusal apart from a request the device has not got to yet, so a regression that wrote - # over the file could still pass on a busy machine. + # The device says so when it refuses a request. Waiting for that message is the only way to + # tell a refusal from a request the device has not got to yet: an unchanged file, or a file + # that never appears, would look the same on a busy machine. refused = asyncio.Event() + busy = asyncio.Event() def on_log(msg) -> None: if REFUSAL_MESSAGE in msg.message: refused.set() + if BUSY_MESSAGE in msg.message: + busy.set() client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG) + # A display records one animation at a time. A second request made while the first is still + # running is refused and leaves no file, and the first one carries on to the end. + slow_animation = next(s for s in services if s.name == "take_slow_animation") + await client.execute_service(slow_animation, {"name": "slow"}) + await client.execute_service(slow_animation, {"name": "second"}) + await asyncio.wait_for(busy.wait(), timeout=10.0) + assert not (snapshot_dir / "second.gif").exists() + await wait_for_gif(snapshot_dir / "slow.gif", 3) + + # A second capture under a name already used must fail rather than write over the first. before = capture.read_bytes() await take(capture.name) await asyncio.wait_for(refused.wait(), timeout=10.0) @@ -73,6 +170,12 @@ async def test_snapshot_display( # Nothing beyond what was asked for, leaving out however many captures it took to wait # for the first frame. written = sorted( - p.name for p in snapshot_dir.iterdir() if not p.name.startswith("drawn-") + p.name for p in snapshot_dir.iterdir() if "drawn-" not in p.name ) - assert written == [".._escape.bmp", "UPPER.BMP"] + assert written == [ + ".._escape.bmp", + "UPPER.BMP", + "movie.gif", + "noise.gif", + "slow.gif", + ] diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index 0567164805e..293824e2f21 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -31,14 +31,25 @@ class ParsedSyslogMessage(TypedDict): # RFC 3164 syslog message pattern: # TIMESTAMP HOSTNAME TAG: MESSAGE # Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running... -SYSLOG_PATTERN = re.compile( +RFC3164_PATTERN = re.compile( r"<(\d+)>" # PRI (priority = facility * 8 + severity) - r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-") - r" (\S+)" # HOSTNAME + r"(?:(\S+ +\d+ \d+:\d+:\d+) )?" # Optional BSD TIMESTAMP + r"(\S+)" # HOSTNAME r" (\S+):" # TAG r" (.*)" # MESSAGE ) +# RFC 5424 syslog message pattern: +# VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG +RFC5424_PATTERN = re.compile( + r"<(\d+)>1 " + r"(\S+) " + r"(\S+) " + r"(\S+) " + r"- - - " + r"(.*)" +) + @dataclass class SyslogReceiver: @@ -123,9 +134,10 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: sock.close() -def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: +def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None: """Parse a syslog message and return its components.""" - match = SYSLOG_PATTERN.match(msg) + pattern = RFC3164_PATTERN if format_ == "RFC3164" else RFC5424_PATTERN + match = pattern.fullmatch(msg) if not match: return None pri, timestamp, hostname, tag, message = match.groups() @@ -137,7 +149,7 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: pri=pri_val, facility=facility, severity=severity, - timestamp=timestamp, + timestamp=timestamp or "", hostname=hostname, tag=tag, message=message, @@ -145,15 +157,18 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: @pytest.mark.asyncio +@pytest.mark.parametrize("format_", ["RFC3164", "RFC5424"]) async def test_syslog( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, + format_: str, ) -> None: """Test syslog component sends properly formatted messages.""" async with syslog_udp_listener() as (udp_port, receiver): # Replace the placeholder port in the config config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port)) + config = config.replace("SYSLOG_FORMAT_PLACEHOLDER", format_) async with run_compiled(config), api_client_connected() as client: # Verify device is running @@ -176,7 +191,7 @@ async def test_syslog( # Parse and validate all messages parsed_messages: list[ParsedSyslogMessage] = [] for msg in receiver.messages: - parsed = parse_syslog_message(msg) + parsed = parse_syslog_message(msg, format_) if parsed: parsed_messages.append(parsed) @@ -204,12 +219,16 @@ async def test_syslog( f"Unexpected hostname: {parsed['hostname']}" ) - # Validate timestamp format (BSD or NILVALUE) - if parsed["timestamp"] != "-": + if format_ == "RFC3164" and parsed["timestamp"]: assert re.match( r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}", parsed["timestamp"], ), f"Invalid timestamp format: {parsed['timestamp']}" + elif format_ == "RFC5424" and parsed["timestamp"] != "-": + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}", + parsed["timestamp"], + ), f"Invalid timestamp format: {parsed['timestamp']}" # Verify we see different severity levels in the logs severities_seen = {p["severity"] for p in parsed_messages} diff --git a/tests/integration/test_template_time.py b/tests/integration/test_template_time.py new file mode 100644 index 00000000000..c0a03fe2352 --- /dev/null +++ b/tests/integration/test_template_time.py @@ -0,0 +1,49 @@ +"""Integration test for the template time platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, TextSensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Timestamps are exposed as text sensors (rather than 32-bit float sensor states, +# which cannot represent a UNIX epoch exactly) so the exact value can be checked. +EXPECTED_STATES = { + "fixed_timestamp": "1700000000", + "offset_timestamp": "1700003600", + "empty_time_valid": "invalid", +} + + +@pytest.mark.asyncio +async def test_template_time( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Verify the template time platform evaluates its lambda on demand.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + + key_to_name = build_key_to_entity_mapping(entities, list(EXPECTED_STATES)) + events = {name: asyncio.Event() for name in EXPECTED_STATES} + + def on_state(state: EntityState) -> None: + if isinstance(state, TextSensorState) and not state.missing_state: + name = key_to_name.get(state.key) + if name is not None and state.state == EXPECTED_STATES[name]: + events[name].set() + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, event in events.items(): + try: + await asyncio.wait_for(event.wait(), timeout=3.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {name} to report its expected value") diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 864275f5ed7..1b877f5948f 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -19,23 +19,40 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState import pytest -from .state_utils import SensorTracker, find_entity, wait_for_state +from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction -@dataclass -class RegisterTestCase: - """Test parameters for a single modbus register write/read round-trip.""" +def _swap16(value: int) -> int: + """Byte-swapped view of a 16-bit register as the raw U_WORD wire value.""" + return ((value & 0xFF) << 8) | (value >> 8) - initial_value: object - write_number_name: str - write_value: float - post_write_value: object + +# Raw U_WORD view of reg_u_word_s's initial 0x1234 +MESH_RAW_U_WORD_S = _swap16(4660) + +# Initial values of the mesh fixture's address 1 registers; the +# server_controller test reads them and the write test uses them as baseline. +MESH_INITIAL_VALUES: dict[str, object] = { + "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_s_word": -99, + "reg_s_word_s": -2, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(2.5), +} # --------------------------------------------------------------------------- @@ -173,6 +190,7 @@ async def test_uart_mock_modbus_no_threshold( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") @pytest.mark.asyncio async def test_uart_mock_modbus_server( yaml_config: str, @@ -203,6 +221,7 @@ async def test_uart_mock_modbus_server( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") @pytest.mark.asyncio async def test_uart_mock_modbus_server_read_write( yaml_config: str, @@ -231,8 +250,8 @@ async def test_uart_mock_modbus_server_read_write( "rw_write_1": 4660, # 0x1234 written to reg 0x0001 "rw_read_1": 4660, # reg 0x0001 reads back the just-written value "rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request - "rw_write_3": 22136, # 0x5678 written to reg 0x0003 - "rw_read_3": 22136, # reg 0x0003 reads back the just-written value + "rw_write_3": 22136, # 0x5678 written to reg 0x0006 + "rw_read_3": 22136, # reg 0x0006 reads back the just-written value } ) @@ -241,10 +260,71 @@ async def test_uart_mock_modbus_server_read_write( api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) - await tracker.await_all(futures) + # The FC 0x17 injections fire behind four earlier 100ms delays + await tracker.await_all(futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_burst( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a server reply deferred behind a queued frame is dropped. + + Two requests are injected as one chunk so both sit in the rx buffer at + once. The reply to the first is deferred because the second is still + queued, and must be discarded once the second frame is parsed: + * device 1 reg 0x0B then device 1 reg 0x0C -- only the 0x0C reply is sent; + * device 1 reg 0x0D then a device 2 request -- nothing is sent. + The fixture's on_tx hook fires a sensor per burst reply that reaches the + wire, and a final plain read marks both cases settled once its reply is + seen. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + [ + "burst_read_a", + "burst_read_b", + "burst_read_before_peer", + "burst_tx_a", + "burst_tx_b", + "burst_tx_before_peer", + "burst_tx_probe", + ] + ) + futures = tracker.expect_all( + { + "burst_read_a": 1, + "burst_read_b": 1, + "burst_read_before_peer": 1, + "burst_tx_b": 1, + "burst_tx_probe": 1, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + # Every request is parsed and served by its read_lambda regardless of + # whether its reply reaches the wire. + await tracker.await_all(futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + assert not tracker.sensor_states["burst_tx_a"], ( + "reply to reg 0x0B must be dropped, a later request was queued behind it" + ) + assert not tracker.sensor_states["burst_tx_before_peer"], ( + "reply to reg 0x0D must be dropped, the client moved on to device 2" + ) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_read_write_invalid( yaml_config: str, @@ -296,6 +376,7 @@ async def test_uart_mock_modbus_server_read_write_invalid( ) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller( yaml_config: str, @@ -306,23 +387,7 @@ async def test_uart_mock_modbus_server_controller( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = { - "reg_u_word": 99, - "reg_u_word_s": 4660, - "reg_u_word_s_raw": 13330, - "reg_s_word": -99, - "reg_s_word_s": -2, - "reg_u_dword": 16909060, - "reg_s_dword": -16909060, - "reg_u_dword_r": pytest.approx(67305985), - "reg_s_dword_r": pytest.approx(-67305985), - "reg_u_qword": pytest.approx(72623859790382856), - "reg_s_qword": pytest.approx(-72623859790382856), - "reg_u_qword_r": pytest.approx(578437695752307201), - "reg_s_qword_r": pytest.approx(-578437695752307201), - "reg_fp32": pytest.approx(3.14), - "reg_fp32_r": pytest.approx(3.14), - } + expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -330,14 +395,12 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_write( yaml_config: str, @@ -353,51 +416,47 @@ async def test_uart_mock_modbus_server_controller_write( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - register_test_cases: dict[str, RegisterTestCase] = { - "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), - "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), - "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), - "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), - "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), - "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), - "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), - "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), - "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), - "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), - "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), - "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), - "reg_fp32": RegisterTestCase( - pytest.approx(1.5, abs=0.01), - "write_fp32", - 3.14, - pytest.approx(3.14, abs=0.01), - ), - "reg_fp32_r": RegisterTestCase( - pytest.approx(2.5, abs=0.01), - "write_fp32_r", - 6.28, - pytest.approx(6.28, abs=0.01), - ), + # Per read-back sensor: the number entity to write through and the value; + # floats read back within tolerance, everything else exactly + register_writes: dict[str, tuple[str, int | float]] = { + "reg_u_word": ("write_u_word", 42), + "reg_u_word_s": ("write_u_word_s", 17185), + "reg_s_word": ("write_s_word", -42), + "reg_s_word_s": ("write_s_word_s", -257), + "reg_u_dword": ("write_u_dword", 2002), + "reg_s_dword": ("write_s_dword", -2002), + "reg_u_dword_r": ("write_u_dword_r", 4004), + "reg_s_dword_r": ("write_s_dword_r", -4004), + "reg_u_qword": ("write_u_qword", 6006), + "reg_s_qword": ("write_s_qword", -6006), + "reg_u_qword_r": ("write_u_qword_r", 8008), + "reg_s_qword_r": ("write_s_qword_r", -8008), + "reg_fp32": ("write_fp32", 6.28), + "reg_fp32_r": ("write_fp32_r", 9.42), } - tracker = SensorTracker(list(register_test_cases.keys())) + tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"]) + # The raw U_WORD view of 0x02 pins the byte swap on the write path: the + # round trip through write_u_word_s applies the swap an even number of + # times, so only the raw sensor can catch a symmetrically dropped swap. # Phase 1: expect initial baseline values initial_futures = tracker.expect_all( - {name: case.initial_value for name, case in register_test_cases.items()} + MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} ) # Phase 2: expect post-write values (registered now so on_state can match them) written_futures = tracker.expect_all( - {name: case.post_write_value for name, case in register_test_cases.items()} + { + name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value + for name, (_, value) in register_writes.items() + } + | {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])} ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the baseline can already be in the - # states the device sends on connect; matching it there saves waiting for - # the next poll entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -406,19 +465,22 @@ async def test_uart_mock_modbus_server_controller_write( # connection is working before issuing writes await tracker.await_all(initial_futures, timeout=4.0) - # Issue write commands for all register types - for case in register_test_cases.values(): - entity = find_entity(entities, case.write_number_name, NumberInfo) - assert entity is not None, ( - f"{case.write_number_name} number entity not found" - ) - client.number_command(entity.key, case.write_value) + # Issue write commands for all register types; exact object_id match, + # since several write_* names are prefixes of a sibling + numbers = { + e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo) + } + for number_name, value in register_writes.values(): + entity = numbers.get(number_name) + assert entity is not None, f"{number_name} number entity not found" + client.number_command(entity.key, value) # Wait for sensors to reflect the written values (round-trip write+read) await tracker.await_all(written_futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_bits( yaml_config: str, @@ -464,8 +526,6 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot and binary sensors drop repeats, so the - # baseline can arrive only in the states the device sends on connect entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -476,8 +536,7 @@ async def test_uart_mock_modbus_server_controller_bits( # Flip both writable bits: 0x02 false -> true, 0x03 true -> false for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): - entity = find_entity(entities, switch_name, SwitchInfo) - assert entity is not None, f"{switch_name} switch entity not found" + entity = require_entity(entities, switch_name, SwitchInfo) client.switch_command(entity.key, value) # Wait for both read views to reflect the written values @@ -485,6 +544,7 @@ async def test_uart_mock_modbus_server_controller_bits( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -495,7 +555,7 @@ async def test_uart_mock_modbus_server_controller_multiple( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = {"reg_u_word": 919, "reg_u_word_2": 929} + expected_values = {"multi_reg_a": 919, "multi_reg_b": 929} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -503,9 +563,6 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -706,6 +763,7 @@ async def test_uart_mock_modbus_shared_address( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_custom_pdu( yaml_config: str, @@ -932,6 +990,7 @@ async def test_uart_mock_modbus_broadcast_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_client_read_write( yaml_config: str, @@ -947,9 +1006,7 @@ async def test_uart_mock_modbus_client_read_write( """ line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - tracker = SensorTracker( - ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] - ) + tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"]) futures = tracker.expect_all( { "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 @@ -967,6 +1024,7 @@ async def test_uart_mock_modbus_client_read_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_register_offset( yaml_config: str, @@ -1022,6 +1080,7 @@ async def test_uart_mock_modbus_register_offset( ) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_lambda_write( yaml_config: str, @@ -1058,6 +1117,7 @@ async def test_uart_mock_modbus_lambda_write( await tracker.await_change(wrote_30, "reg_30", timeout=4.0) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_lambda_invert( yaml_config: str, @@ -1113,6 +1173,7 @@ async def test_uart_mock_modbus_lambda_invert( ) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_deprecated_write_buffer( yaml_config: str, diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index d63d1d69845..3d7f8851605 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -155,3 +155,36 @@ async def test_water_heater_template( client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO + + +@pytest.mark.asyncio +async def test_water_heater_template_unknown_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a template water heater whose temperature lambdas stay unknown. + + NAN never compares equal to itself, so a lambda that keeps returning NAN must not be + mistaken for a changed value and republish the state on every loop iteration. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + state_count = 0 + + def on_state(state: aioesphomeapi.EntityState) -> None: + nonlocal state_count + if isinstance(state, WaterHeaterState): + state_count += 1 + + entities, _ = await client.list_entities_services() + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1 + + client.subscribe_states(on_state) + + # Let the device run for a while; only the single initial state may arrive. + await asyncio.sleep(1.0) + assert state_count <= 1, ( + f"Expected at most 1 state publish, got {state_count} - " + "an unknown (NAN) temperature is republishing every loop" + ) diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index f3d4bbcba65..121a84e5aa3 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -248,6 +248,41 @@ def test_convert_keys_bare_callable_dotted_qualname() -> None: assert set(converted["schema"]["config_vars"]) == {"string"} +@pytest.fixture(scope="module") +def language_schema_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Run the full language-schema build once and return the output directory. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps these tests isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return out_dir + + +def test_uart_clock_source_preserves_variant_metadata( + language_schema_dir: Path, +) -> None: + """UART clock choices retain chip restrictions in the editor schema.""" + uart_schema = json.loads((language_schema_dir / "uart.json").read_text()) + clock_source = uart_schema["uart"]["schemas"]["CONFIG_SCHEMA"]["schema"][ + "config_vars" + ]["clock_source"] + + assert clock_source["type"] == "enum" + assert clock_source["values"]["REF_TICK"] == {"variants": ["ESP32", "ESP32S2"]} + assert "ESP32C6" in clock_source["values"]["XTAL"]["variants"] + assert "ESP32C6" not in clock_source["values"]["APB"]["variants"] + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # @@ -261,23 +296,8 @@ def test_convert_keys_bare_callable_dotted_qualname() -> None: @pytest.fixture(scope="module") -def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: - """Run the full language-schema build once and return parsed lvgl.json. - - The build must run in a fresh interpreter: ``build_language_schema.py`` - enables schema extraction *before* importing any esphome component, and the - extraction hooks are no-ops if the components were already imported (as they - are inside the pytest session). Running it as a subprocess mirrors how CI - generates the schema and keeps this test isolated from import order. - """ - out_dir = tmp_path_factory.mktemp("language_schema") - subprocess.run( - [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], - check=True, - capture_output=True, - text=True, - ) - return json.loads((out_dir / "lvgl.json").read_text()) +def lvgl_schema(language_schema_dir: Path) -> dict: + return json.loads((language_schema_dir / "lvgl.json").read_text()) def _lvgl_config_vars(lvgl_schema: dict) -> dict: diff --git a/tests/script/test_check_idf_py_equivalence.py b/tests/script/test_check_idf_py_equivalence.py new file mode 100644 index 00000000000..f8135772d0c --- /dev/null +++ b/tests/script/test_check_idf_py_equivalence.py @@ -0,0 +1,248 @@ +"""Tests for script/check_idf_py_equivalence.py.""" + +from collections.abc import Callable, Iterator +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import check_idf_py_equivalence as guard # noqa: E402 + +from esphome.core import CORE # noqa: E402 +from esphome.espidf import toolchain # noqa: E402 + + +@pytest.fixture(autouse=True) +def _reset_core() -> Iterator[None]: + """check() points the global CORE at the tree it inspects.""" + yield + CORE.reset() + + +def _make_tree(tmp_path: Path) -> Path: + tree = tmp_path / "config" / ".esphome" / "build" / "dev" + build = tree / "build" + for name in (*guard.watched("dev"), *guard.NINJA_LOGS): + (tree / name).parent.mkdir(parents=True, exist_ok=True) + (tree / name).write_bytes(b"x") + (build / "project_description.json").write_text( + json.dumps( + { + "project_name": "dev", + "idf_path": "/idf/frameworks/5.5.5", + "target": "esp32", + } + ) + ) + (build / ".ninja_log").write_text( + "# ninja log v7\n1\t2\t10\tesp-idf/a.obj\t0\n" + "1\t2\t10\tbootloader/bootloader.bin\t0\n" + ) + (build / "bootloader" / ".ninja_log").write_text( + "# ninja log v7\n1\t2\t10\tbootloader.elf\t0\n" + ) + (tree / "sdkconfig.dev").write_text("") + return tree + + +def _run_check( + tree: Path, + side_effect: Callable[[list[str]], None] = lambda cmd: None, + rc: int = 0, + esphome_rcs: tuple[int, int] = (0, 0), +) -> tuple[list[str], list[list[str]]]: + """Run check() with idf.py replaced by ``side_effect``; return problems, calls.""" + calls: list[list[str]] = [] + + def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess: + calls.append(cmd) + side_effect(cmd) + return subprocess.CompletedProcess(cmd, rc, "out\n", "err\n") + + with ( + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain, "_get_idf_tool", return_value="/py"), + patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")), + patch.object(toolchain, "run_reconfigure", return_value=esphome_rcs[0]), + patch.object(toolchain, "_run_ninja", return_value=esphome_rcs[1]), + patch.object(guard.subprocess, "run", side_effect=run), + ): + return guard.check(tree), calls + + +def test_check_passes_when_idf_py_changes_nothing(tmp_path: Path) -> None: + tree = _make_tree(tmp_path) + problems, calls = _run_check(tree) + assert problems == [] + sdkconfig = f"SDKCONFIG={tree / 'sdkconfig.dev'}" + assert calls == [ + ["/py", str(Path("/idf/tools/idf.py")), "-D", sdkconfig, "reconfigure"], + ["/py", str(Path("/idf/tools/idf.py")), "-D", sdkconfig, "build"], + ] + + +def test_check_reports_changed_files_and_rebuilt_outputs(tmp_path: Path) -> None: + tree = _make_tree(tmp_path) + build = tree / "build" + + def drift(cmd: list[str]) -> None: + if cmd[-1] == "reconfigure": + (build / "CMakeCache.txt").write_text("changed") + return + # Compacted log. The re-logged bootloader byproduct and a stamp are + # not work; a new object mtime is. + (build / ".ninja_log").write_text( + "# ninja log v7\n3\t4\t20\tesp-idf/a.obj\t0\n" + "5\t6\t30\tbootloader/bootloader.bin\t0\n" + "5\t6\t30\tbootloader-stamp\t0\n" + ) + # The bootloader sub-build is judged by its own log. + (build / "bootloader" / ".ninja_log").write_text( + "# ninja log v7\n1\t2\t40\tbootloader.elf\t0\n" + ) + + problems, _ = _run_check(tree, drift) + assert problems == [ + "idf.py changed build/CMakeCache.txt", + "idf.py rebuilt esp-idf/a.obj", + "idf.py rebuilt bootloader.elf", + ] + + +@pytest.mark.parametrize( + ("after_build", "problem"), + [ + (lambda log: log.unlink(), "missing build/.ninja_log"), + ( + lambda log: log.write_text("# ninja log v7\n"), + "no build entries parsed from build/.ninja_log", + ), + ( + lambda log: log.write_text("# ninja log v7\n1\t2\t10\tesp-idf/b.obj\t0\n"), + "idf.py dropped esp-idf/a.obj from build/.ninja_log", + ), + ], + ids=["log-removed", "log-emptied", "entry-dropped"], +) +def test_check_reports_a_log_idf_py_left_unusable( + tmp_path: Path, after_build: Callable[[Path], None], problem: str +) -> None: + """The comparison side gets the same log checks as the baseline.""" + tree = _make_tree(tmp_path) + log = tree / guard.TOP_NINJA_LOG + + def run(cmd: list[str]) -> None: + if cmd[-1] == "build": + after_build(log) + + problems, _ = _run_check(tree, run) + assert problem in problems + + +def test_check_stops_when_idf_py_fails(tmp_path: Path) -> None: + problems, calls = _run_check(_make_tree(tmp_path), rc=2) + assert problems == ["idf.py reconfigure failed:\nout\nerr\n"] + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "remove", ["build/build.ninja", "build/dev.bin", *guard.NINJA_LOGS] +) +def test_check_fails_when_an_input_is_missing(tmp_path: Path, remove: str) -> None: + """A moved or renamed output must not compare as unchanged.""" + tree = _make_tree(tmp_path) + (tree / remove).unlink() + problems, calls = _run_check(tree) + assert problems == [f"missing {remove}"] + assert calls == [] + + +@pytest.mark.parametrize( + ("esphome_rcs", "problem"), + [ + ((3, 0), "ESPHome's CMake configure failed with exit code 3"), + ((0, 4), "ESPHome's ninja build failed with exit code 4"), + ], + ids=["configure", "build"], +) +def test_check_stops_when_the_esphome_baseline_fails( + tmp_path: Path, esphome_rcs: tuple[int, int], problem: str +) -> None: + """The baseline is ESPHome's own reconfigure and build.""" + problems, calls = _run_check(_make_tree(tmp_path), esphome_rcs=esphome_rcs) + assert problems == [problem] + assert calls == [] + + +@pytest.mark.parametrize("log", guard.NINJA_LOGS) +def test_check_fails_when_a_ninja_log_has_no_entries(tmp_path: Path, log: str) -> None: + """A log format change must not leave the rebuild check with nothing to compare.""" + tree = _make_tree(tmp_path) + (tree / log).write_text("# ninja log v99\n1 2 3\n") + problems, calls = _run_check(tree) + assert problems == [f"no build entries parsed from {log}"] + assert calls == [] + + +def test_main_rejects_a_path_that_is_not_a_tree( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + tree = _make_tree(tmp_path / "a") + stale = tmp_path / "stale" + with ( + patch.object(sys, "argv", ["check", str(tree), str(stale)]), + patch.object(guard, "check", return_value=[]) as mock_check, + ): + assert guard.main() == 1 + assert f"{stale}: not a configured native ESP-IDF build tree" in ( + capsys.readouterr().out + ) + mock_check.assert_not_called() + + +def test_main_without_build_trees( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object(sys, "argv", ["check"]), + patch.object(guard, "REPO_ROOT", tmp_path), + ): + assert guard.main() == 1 + assert "No native ESP-IDF build tree found" in capsys.readouterr().out + + +@pytest.mark.parametrize(("problems", "rc"), [([], 0), (["idf.py changed x"], 1)]) +def test_main_reports_each_tree( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + problems: list[str], + rc: int, +) -> None: + tree = _make_tree(tmp_path) + with ( + patch.object(sys, "argv", ["check", str(tree)]), + patch.object(guard, "check", return_value=problems), + ): + assert guard.main() == rc + out = capsys.readouterr().out + assert f"{tree}: {'DIFFERS' if problems else 'OK'}" in out + assert ("no longer matches idf.py" in out) is bool(problems) + + +def test_main_checks_only_the_first_found_tree(tmp_path: Path) -> None: + """The contract does not depend on the target; one tree per batch is enough.""" + first = _make_tree(tmp_path / "a") + _make_tree(tmp_path / "b") + with ( + patch.object(sys, "argv", ["check"]), + patch.object(guard, "REPO_ROOT", tmp_path), + patch.object(guard, "DEFAULT_GLOB", "*/config/.esphome/build/*"), + patch.object(guard, "check", return_value=[]) as mock_check, + ): + assert guard.main() == 0 + mock_check.assert_called_once_with(first) diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index d340a816c6d..5a943cda083 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -1,15 +1,21 @@ -"""Unit tests for the ESP_LOG-needs-braces lint rule in script/ci-custom.py. +"""Unit tests for the ESP_LOG-needs-braces and std::nothrow lint rules in script/ci-custom.py. + +The nothrow rule is a masked lint_re_check, so its tests also pin the decorator's mask option. The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() call (which becomes an empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the NOLINT escape hatch at both placements a contributor would try. + +Also covers the ESP_LOG call scanner (_iter_log_calls) and the bare-literal-ternary lint. """ import importlib.util from pathlib import Path import sys +import pytest + SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve() sys.path.insert(0, str(SCRIPT_DIR)) _spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py") @@ -145,3 +151,238 @@ def test_nolint_at_end_of_log_line_suppresses() -> None: def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") + + +# --- std::nothrow --- + + +def _lint_nothrow(content: str) -> list: + return ci_custom.lint_no_std_nothrow("test.cpp", content) + + +def test_nothrow_is_reported_at_its_line_and_column_and_points_at_ramallocator() -> ( + None +): + errors = _lint_nothrow( + "int a;\nint b;\n auto *p = new (std::nothrow) uint8_t[n];\n" + ) + assert [(line, col) for line, col, _msg in errors] == [(3, 18)] + assert "RAMAllocator" in errors[0][2] + + +def test_nothrow_spacing_and_the_nothrow_t_type() -> None: + assert len(_lint_nothrow("auto *p = new (std :: nothrow) Foo;\n")) == 1 + assert not _lint_nothrow( + "void *operator new(size_t n, const std::nothrow_t &) noexcept;\n" + ) + + +def test_nothrow_in_comments_and_strings_is_masked() -> None: + assert not _lint_nothrow("// new (std::nothrow) aborts on ESP-IDF\n") + assert not _lint_nothrow('ESP_LOGD(TAG, "std::nothrow");\n') + + +def test_nothrow_nolint_suppresses() -> None: + assert not _lint_nothrow("auto *p = new (std::nothrow) Foo; // NOLINT\n") + + +def test_nothrow_nolint_inside_a_string_does_not_suppress() -> None: + assert len(_lint_nothrow('auto *p = new (std::nothrow) Foo; log("NOLINT");\n')) == 1 + + +# --- rule: UNIT_ constants must not be redefined (mirror of the CONF_ check) --- + +# Real UNIT_ constants that live in each canonical home. +UNIT_IN_CONST_PY = ci_custom.UNIT_CONSTANTS[0] +UNIT_IN_COMPONENT_CONST = ci_custom.COMPONENT_UNIT_CONSTANTS[0] + + +def _unit_def(fname: str, content: str) -> list: + return ci_custom.lint_unit_from_const_py(fname, content) + + +def test_unit_already_in_const_py_is_flagged() -> None: + errs = _unit_def("esphome/components/x/sensor.py", f'{UNIT_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_unit_already_in_component_const_is_flagged() -> None: + errs = _unit_def( + "esphome/components/x/sensor.py", f'{UNIT_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_unit_not_in_const_py_is_tracked_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + assert _unit_def("a.py", 'UNIT_FOO_BAR = "fb"\n') == [] + assert ci_custom.UNIT_CONSTANTS_USES["UNIT_FOO_BAR"] == ["a.py"] + + +def test_unit_defined_in_three_files_is_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py", "c.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + errs = ci_custom.lint_unit_constants_usage() + assert any("UNIT_FOO_BAR" in e and "3 files" in e for e in errs) + + +def test_unit_defined_in_two_files_is_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + assert ci_custom.lint_unit_constants_usage() == [] + + +# --- same rule for CONF_, now also recognising the components/const home --- + +CONF_IN_CONST_PY = ci_custom.CONSTANTS[0] +CONF_IN_COMPONENT_CONST = ci_custom.COMPONENT_CONSTANTS[0] + + +def _conf_def(fname: str, content: str) -> list: + return ci_custom.lint_conf_from_const_py(fname, content) + + +def test_conf_already_in_const_py_is_flagged() -> None: + errs = _conf_def("esphome/components/x/sensor.py", f'{CONF_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_conf_already_in_component_const_is_flagged() -> None: + errs = _conf_def( + "esphome/components/x/sensor.py", f'{CONF_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_conf_not_in_a_const_home_is_tracked_not_flagged() -> None: + ci_custom.CONSTANTS_USES.pop("CONF_FOO_BAR", None) + assert _conf_def("a.py", 'CONF_FOO_BAR = "foo_bar"\n') == [] + assert ci_custom.CONSTANTS_USES["CONF_FOO_BAR"] == ["a.py"] + + +# --- ESP_LOG call scanner and bare-literal-ternary lint --- + + +def _calls(content: str) -> list[str | None]: + return [text for _, text in ci_custom._iter_log_calls(content)] + + +def _ternary_errors(content: str) -> list[tuple[int, int]]: + errs = ci_custom.lint_log_no_bare_literal_ternary(Path("x.cpp"), content) + return [(line, col) for line, col, _ in errs] + + +@pytest.mark.parametrize( + "content", + [ + 'ESP_LOGD(TAG, "a ) b ( c; d")', + 'ESP_LOGD(TAG, "quote \\" inside")', + "ESP_LOGD(TAG, \"%s\", format_hex_pretty(x, '-', false).c_str())", + "ESP_LOGD(TAG, \"%c%c\", '(', ')')", + "ESP_LOGD(TAG, \"%d\", 1'000'000)", + 'ESP_LOGD(TAG, // it\'s a comment with ) and (\n "x")', + 'ESP_LOGD(TAG, /* :) */ "x")', + 'ESP_LOGD(TAG, "%s", R"(say "hi" :) )")', + 'ESP_LOGD(TAG, "%s", R"x(a)"b)x")', + ], +) +def test_iter_log_calls_spans_whole_call(content: str) -> None: + calls = _calls(content + ";\nint other = (1);") + assert calls == [content] + + +def test_iter_log_calls_reports_unbalanced_call_once() -> None: + content = 'ESP_LOGD(TAG, "x";\nvoid f();' + assert _calls(content) == [None] + errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content) + assert len(errs) == 1 + assert errs[0][:2] == (1, 1) + assert "no matching closing parenthesis" in errs[0][2] + assert _ternary_errors(content) == [] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + # A ; inside the format string no longer cuts the call short + ('ESP_LOGD(TAG, "a; b\\nc %s", x);', [(1, 20)]), + # A \n%s continuation is exempt since %s may expand to leading whitespace + ('ESP_LOGD(TAG, "a\\n%s", x);', []), + ('ESP_LOGD(TAG, "a\\n b");', []), + ], +) +def test_multiline_continuation_detection( + content: str, expected: list[tuple[int, int]] +) -> None: + errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content) + assert [(line, col) for line, col, _ in errs] == expected + + +def test_exclusion_list_only_names_components_without_esp8266_tests() -> None: + root = Path(__file__).parent / ".." / ".." + for pattern in ci_custom.LOG_LITERAL_LINT_EXCLUDE: + if not pattern.startswith("esphome/components/"): + continue + prefix = pattern.removeprefix("esphome/components/").split("/")[0] + comps = list((root / "esphome" / "components").glob(prefix)) + assert comps, f"{pattern!r} matches no component" + for comp in comps: + test = root / "tests" / "components" / comp.name / "test.esp8266-ard.yaml" + assert not test.exists(), ( + f"{comp.name} builds for ESP8266, drop {pattern!r}" + ) + + +def test_unbalanced_calls_are_reported_by_a_check_that_sees_every_file() -> None: + # lint_log_no_bare_literal_ternary skips unbalanced calls and relies on this + checks = {c["func"].__name__: c for c in ci_custom.LINT_CONTENT_CHECKS} + continuation = checks["lint_log_multiline_continuation"] + ternary = checks["lint_log_no_bare_literal_ternary"] + assert continuation["exclude"] == [] + assert continuation["include"] == ternary["include"] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ('ESP_LOGD(TAG, "%s", x ? "on" : "off");', [(1, 25), (1, 32)]), + ( + 'ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));', + [], + ), + ('ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : "off");', [(1, 49)]), + ('ESP_LOGD(TAG, "%s", x ? "on" : "");', [(1, 25)]), + ( + 'ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no");', + [(2, 14), (3, 14)], + ), + ("ESP_LOGD(TAG, \"%c\", x ? '1' : '0');", []), + ('ESP_LOGD(TAG, "a ? b : c %s", x ? "on" : "off");', [(1, 35), (1, 42)]), + ('ESP_LOGD(TAG, "x:" "y %s", p);', []), + ('ESP_LOGD(TAG, "%s", x ? "on" : "off"); // NOLINT', []), + ('ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no"); // NOLINT', []), + ('ESP_LOGD(TAG, "%s", x ? /* c */ "on" : "off");', [(1, 33), (1, 40)]), + ( + 'ESP_LOGD(TAG, "%s",\n x ? "on" // NOLINT(some-clang-check)\n : "off");', + [(2, 14), (3, 14)], + ), + ], +) +def test_ternary_literal_detection( + content: str, expected: list[tuple[int, int]] +) -> None: + assert _ternary_errors(content) == expected + + +def test_ternary_error_message_names_the_literal() -> None: + errs = ci_custom.lint_log_no_bare_literal_ternary( + Path("x.cpp"), 'ESP_LOGD(TAG, "%s", x ? "enabled" : LOG_STR_LITERAL("off"));' + ) + assert len(errs) == 1 + assert 'LOG_STR_LITERAL("enabled")' in errs[0][2] diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 49718219698..cea2a2bfe6f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -78,6 +78,17 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]: + """Mock esp8266_native_components_to_test from determine_jobs. + + main() drives both the ``esp8266_native`` boolean output and the + ``esp8266_native_components`` CSV from this one function. + """ + with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -106,6 +117,7 @@ def clear_determine_jobs_caches() -> None: """Clear all cached functions before each test.""" determine_jobs._is_clang_tidy_full_scan.cache_clear() determine_jobs._component_has_tests.cache_clear() + determine_jobs._cached_components_closure.cache_clear() def test_main_all_tests_should_run( @@ -116,6 +128,7 @@ def test_main_all_tests_should_run( mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -132,6 +145,7 @@ def test_main_all_tests_should_run( mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] + mock_esp8266_native_components_to_test.return_value = ["api", "logger"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -208,6 +222,8 @@ def test_main_all_tests_should_run( assert output["device_builder"] is True assert output["esp32_platformio"] is True assert output["esp32_platformio_components"] == "api,esp32" + assert output["esp8266_native"] is True + assert output["esp8266_native_components"] == "api,logger" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -233,7 +249,130 @@ def test_main_all_tests_should_run( # Should contain at least one component (no empty batches) assert len(batch["components"]) > 0 assert isinstance(batch["needs_idf"], bool) + assert isinstance(batch["check_idf_py"], bool) assert isinstance(batch["needs_nrf"], bool) + assert isinstance(batch["needs_arduino8266"], bool) + + +def _batch(components: str, idf: bool, check: bool, a8266: bool) -> dict: + return { + "components": components, + "needs_idf": idf, + "check_idf_py": check, + "needs_nrf": False, + "needs_arduino8266": a8266, + } + + +@pytest.mark.parametrize( + ("batches", "component_platforms", "validate_only", "expected"), + [ + # The compile stage builds test-..yaml too, so a + # component tested on esp8266 only by a variant still needs the toolchain. + ( + [["safe_mode"]], + {"safe_mode": {"esp8266-ard"}}, + set(), + [_batch("safe_mode", idf=False, check=False, a8266=True)], + ), + # Only the first esp32 batch runs the idf.py equivalence check. + ( + [["a"], ["b"], ["c"]], + {"a": {"esp8266-ard"}, "b": {"esp32-idf"}, "c": {"esp32-c3-idf"}}, + set(), + [ + _batch("a", idf=False, check=False, a8266=True), + _batch("b", idf=True, check=True, a8266=False), + _batch("c", idf=True, check=False, a8266=False), + ], + ), + # A validate-only batch never compiles, so the next esp32 batch runs it. + ( + [["b"], ["c"]], + {"b": {"esp32-idf"}, "c": {"esp32-c3-idf"}}, + {"b"}, + [ + _batch("b", idf=True, check=False, a8266=False), + _batch("c", idf=True, check=True, a8266=False), + ], + ), + ], + ids=["variant", "idf-check-once", "idf-check-skips-validate-only"], +) +def test_main_batch_flags( + batches: list[list[str]], + component_platforms: dict[str, set[str]], + validate_only: set[str], + expected: list[dict], + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each batch carries the toolchain flags its test platforms need.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_esp8266_native_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = ["esphome/components/safe_mode/__init__.py"] + + def platforms(component: str, *, base_only: bool = True) -> set[str]: + return set() if base_only else component_platforms[component] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, + "get_changed_components", + return_value=[c for batch in batches for c in batch], + ), + patch.object( + determine_jobs, + "_component_change_is_validate_only", + side_effect=lambda component, changed: component in validate_only, + ), + patch.object( + determine_jobs, + "filter_component_and_test_files", + side_effect=lambda f: f.startswith("esphome/components/"), + ), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=[c for batch in batches for c in batch], + ), + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=(batches, {}) + ), + patch.object( + determine_jobs, "get_component_test_platforms", side_effect=platforms + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + assert output["component_test_batches"] == expected def test_main_no_tests_should_run( @@ -244,6 +383,7 @@ def test_main_no_tests_should_run( mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -260,6 +400,7 @@ def test_main_no_tests_should_run( mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False mock_esp32_platformio_components_to_test.return_value = [] + mock_esp8266_native_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -302,6 +443,8 @@ def test_main_no_tests_should_run( assert output["device_builder"] is False assert output["esp32_platformio"] is False assert output["esp32_platformio_components"] == "" + assert output["esp8266_native"] is False + assert output["esp8266_native_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -993,10 +1136,16 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [ # PlatformIO subsystem (path-prefix trigger) + build generator ["esphome/platformio/runner.py"], ["esphome/platformio/toolchain.py"], + # Decides which platforms take the pch script + ["esphome/build_helpers/pch.py"], + ["esphome/build_helpers/ccache.py"], ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], + # The base config and bus packages every generated build includes + ["tests/test_build_components/build_components_base.esp32-idf.yaml"], + ["tests/test_build_components/common/uart/esp32-idf.yaml"], ] @@ -1051,6 +1200,9 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the # full list -- only esphome/build_gen/platformio.py is a trigger. (["esphome/build_gen/espidf.py"], [], []), + # The rest of build_helpers/ is not a trigger. + (["esphome/build_helpers/size_summary.py"], [], []), + (["esphome/build_helpers/ninja.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), @@ -1126,8 +1278,10 @@ def test_should_run_esp32_platformio_with_branch() -> None: @pytest.mark.parametrize( ("changed_files", "expected"), [ - # ESP-IDF runner / framework / build generator -> trigger - (["esphome/espidf/runner.py"], True), + # ESP-IDF toolchain / framework / build generator -> trigger + (["esphome/espidf/toolchain.py"], True), + (["esphome/build_helpers/tool_runner.py"], True), + (["script/check_idf_py_equivalence.py"], True), (["esphome/espidf/framework.py"], True), (["esphome/build_gen/espidf.py"], True), # Shared native-build modules the IDF build imports -> trigger @@ -1145,7 +1299,7 @@ def test_should_run_esp32_platformio_with_branch() -> None: ], ) def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: - """ESP-IDF build/runner infra paths are detected; other paths are not.""" + """ESP-IDF build infra paths are detected; other paths are not.""" assert determine_jobs._esp_idf_infra_changed(changed_files) is expected @@ -1507,6 +1661,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non assert set(result["components"]) == {"wifi", "api"} assert result["platform"] == "esp32-idf" # Common platform assert result["use_merged_config"] == "true" + assert result["needs_arduino8266"] is False @pytest.mark.usefixtures("mock_target_branch_dev") @@ -1611,6 +1766,8 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["platform"] == "esp8266-ard" assert result["components"] == ["logger"] assert result["use_merged_config"] == "true" + # The esp8266 build is native, so the job restores that toolchain + assert result["needs_arduino8266"] is True @pytest.mark.usefixtures("mock_target_branch_dev") @@ -3151,6 +3308,81 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None: assert find_elf_path(build_path) == elf, f"{platform} ELF not found" +@pytest.mark.parametrize( + "changed", + [ + "esphome/arduino8266/framework.py", + "esphome/build_gen/arduino8266.py", + "esphome/components/esp8266/build_surgery.py", + # Shared modules the native build depends on + "esphome/build_helpers/idedata.py", + "esphome/platformio/library.py", + # Top-level esphome/*.py modules the backend imports directly + "esphome/framework_helpers.py", + "esphome/writer.py", + # esp8266/__init__.py imports copy_ccache_script from it + "esphome/platformio/toolchain.py", + # The composite cache action must not ship unexercised + ".github/actions/cache-arduino8266/action.yml", + # The base config and bus packages every generated build includes + "tests/test_build_components/build_components_base.esp8266-ard.yaml", + "tests/test_build_components/common/uart/esp8266-ard.yaml", + ], +) +def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None: + """Native-ESP8266 infrastructure changes run the full test list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=["wifi"], + ), + ): + result = determine_jobs.esp8266_native_components_to_test() + assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS) + + +@pytest.mark.parametrize( + ("changed_files", "dependency_closure", "expected"), + [ + # A tested component alone does not schedule this job: the component + # matrix already compiles its esp8266 fixtures with this toolchain. + ( + ["esphome/components/mqtt/mqtt_client.cpp"], + ["mqtt", "json"], + [], + ), + ( + ["esphome/components/wifi/wifi_component.cpp"], + ["wifi", "network"], + [], + ), + # espidf infrastructure is not an esp8266-native trigger; the + # native backend depends on esphome/build_helpers/ instead. + (["esphome/build_gen/espidf.py"], [], []), + (["esphome/espidf/toolchain.py"], [], []), + (["README.md"], [], []), + ], +) +def test_esp8266_native_components_to_test_narrowing( + changed_files: list[str], + dependency_closure: list[str], + expected: list[str], +) -> None: + """Only a native-build change schedules the native-ESP8266 job.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=dependency_closure, + ), + ): + result = determine_jobs.esp8266_native_components_to_test() + assert result == expected + + def test_compute_integration_test_buckets_no_durations_full_fanout() -> None: """Without recorded durations the fan-out stays at the maximum.""" files = [f"tests/integration/test_{i:03d}.py" for i in range(15)] diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 7d4059da2f7..ded7ef0a722 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -2122,6 +2122,31 @@ def test_get_cpp_changed_components_independent_of_cwd( ) == ["time"] +def test_fixture_map_includes_shared_yaml_markers() -> None: + """Fixtures named only by shared_yaml markers must map to their test file.""" + helpers.get_fixture_to_test_files.cache_clear() + mapping = helpers.get_fixture_to_test_files() + for fixture in ( + "uart_mock_modbus_loopback", + "uart_mock_modbus_mesh", + "uart_mock_modbus_server_injected", + ): + assert mapping[fixture] == frozenset( + {"tests/integration/test_uart_mock_modbus.py"} + ) + + +def test_no_orphan_integration_fixtures() -> None: + """Every fixture must reach CI test selection; an orphan selects nothing.""" + helpers.get_fixture_to_test_files.cache_clear() + mapping = helpers.get_fixture_to_test_files() + fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve() + fixtures = list(fixtures_dir.glob("*.yaml")) + assert fixtures, f"no fixtures found under {fixtures_dir}" + orphans = [f.stem for f in fixtures if f.stem not in mapping] + assert not orphans, f"fixtures invisible to CI test selection: {orphans}" + + def test_lpt_partition_balances_skewed_weights() -> None: """Heavy items spread across groups instead of clustering.""" items = [f"i{n}" for n in range(6)] diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py deleted file mode 100644 index a263d7937fb..00000000000 --- a/tests/script/test_platformio_install_deps.py +++ /dev/null @@ -1,649 +0,0 @@ -"""Tests for script/platformio_install_deps.py.""" - -from argparse import Namespace -import importlib.util -import inspect -from pathlib import Path -import shutil -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from platformio import fs -from platformio.cache import ContentCache -from platformio.exception import InvalidJSONFile -from platformio.package.manager._install import PackageManagerInstallMixin -from platformio.package.manager.base import BasePackageManager -from platformio.package.manager.library import LibraryPackageManager -from platformio.package.manager.tool import ToolPackageManager -from platformio.package.meta import PackageCompatibility, PackageItem, PackageSpec -import pytest -from semantic_version import Version - -_SCRIPT = Path(__file__).parents[2] / "script" / "platformio_install_deps.py" - - -def _load_script(): - spec = importlib.util.spec_from_file_location("platformio_install_deps", _SCRIPT) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # The real ContentCache would create dirs under the user's core dir - module.ContentCache = lambda *_: None - return module - - -def test_spec_key_collapses_destinations() -> None: - """Two specs delivering one package share a directory and one key.""" - mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c" - assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( - "esp32async/asynctcp @ 3.5.0" - ) - url = "https://github.com/pioarduino/platform-espressif32/releases/download/{v}/platform-espressif32.zip" - assert mod.spec_key(url.format(v="55.03.311")) == mod.spec_key( - url.format(v="54.03.20") - ) - - -def test_parse_specs_and_cli_args(tmp_path: Path) -> None: - """Parsing skips unpinned and interpolated entries; the CLI rebuild - keeps the original flag pairing.""" - ini = tmp_path / "platformio.ini" - ini.write_text( - "[env:a]\n" - "platform = fake/platform@1\n" - "lib_deps =\n" - " esphome/noise-c @ 0.1.21\n" - " ${common.lib_deps}\n" - " internal_lib\n" - "[env:b]\n" - "lib_deps =\n" - " esphome/noise-c @ 0.1.21\n" - ) - mod = _load_script() - args = Namespace(libraries=True, platforms=True, tools=False) - libs, platforms, tools = mod.parse_specs(str(ini), args) - # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.21"] - assert platforms == ["fake/platform@1"] - assert tools == [] - assert mod.build_cli_args(libs, platforms, tools) == [ - "-l", - "esphome/noise-c @ 0.1.21", - "-p", - "fake/platform@1", - ] - - -class _FakeManager: - """Scripted manager_cls: records installs, raises on demand.""" - - installed: set = set() - fail: set = set() - calls: list = [] - lock_events: list = [] - base_dir: str = "" # per-test tmp base; set by _reset_fake - - def __init__(self, package_dir) -> None: - assert package_dir is None - - @staticmethod - def _key(spec) -> str: - return spec if isinstance(spec, str) else str(spec) - - def get_package(self, spec): - if self._key(spec) in self.installed: - return SimpleNamespace(path="/tmp/fake-pkg", spec=self._key(spec)) - return None - - def memcache_reset(self) -> None: - type(self).resets = getattr(type(self), "resets", 0) + 1 - - @property - def package_dir(self) -> str: - return str(Path(type(self).base_dir) / "packages") - - def get_download_dir(self) -> str: - return str(Path(type(self).base_dir) / "downloads") - - def get_tmp_dir(self) -> str: - return str(Path(type(self).base_dir) / "tmp") - - def lock(self) -> None: - type(self).lock_events.append("lock") - - def unlock(self) -> None: - type(self).lock_events.append("unlock") - - def _install(self, spec, skip_dependencies, compatibility=None): - assert skip_dependencies is True - if self._key(spec) in self.fail: - raise RuntimeError("boom") - type(self).calls.append(spec) - type(self).compat_calls.append((self._key(spec), compatibility)) - type(self).installed.add(self._key(spec)) # atomic under the GIL - - def get_pkg_dependencies(self, pkg): - return getattr(type(self), "deps", {}).get(pkg.spec) - - dependency_to_spec = staticmethod(BasePackageManager.dependency_to_spec) - - -def _reset_fake(base_dir: str = "", **kwargs) -> type: - # A fresh subclass per test: nothing leaks between tests through the - # class-level scripted state - return type( - "_ScriptedManager", - (_FakeManager,), - { - "base_dir": base_dir, - "installed": kwargs.get("installed", set()), - "fail": kwargs.get("fail", set()), - "calls": [], - "compat_calls": [], - "lock_events": [], - }, - ) - - -def test_parallel_install_empty_specs_is_a_no_op(tmp_path: Path) -> None: - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - mod.parallel_install(cls, []) - assert cls.calls == [] and cls.lock_events == [] - - -def test_parallel_install_behavior(tmp_path: Path) -> None: - """Duplicates collapse to one install, installed specs are filtered, - URL specs stay out of the wave, and the lock wraps the pool.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/already @ 1.0"}) - mod.parallel_install( - cls, - [ - "esphome/noise-c @ 0.1.21", - "esphome/noise-c @ 0.1.21", - "esphome/already @ 1.0", - "https://x/framework.tar.xz", - ], - ) - assert cls.calls == ["esphome/noise-c @ 0.1.21"] - assert cls.lock_events == ["lock", "unlock"] - - -def test_parallel_install_failure_cleans_torn_destination( - tmp_path: Path, capsys -) -> None: - """A failed install resets the memcache, removes what get_package can - see, and reports; the others still install.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - - removed = [] - - torn = str(tmp_path / "packages" / "torn-pkg") # never created; only rmtree'd - - def get_package(self, spec): - if spec == "esphome/bad @ 1.0" and getattr(cls, "resets", 0): - return SimpleNamespace(path=torn, spec=spec) - return _FakeManager.get_package(self, spec) - - cls.get_package = get_package # throwaway subclass; nothing to restore - with patch.object(mod.fs, "rmtree", side_effect=removed.append): - mod.parallel_install(cls, ["esphome/bad @ 1.0", "esphome/good @ 1.0"]) - assert "esphome/good @ 1.0" in cls.calls - assert removed == [torn] - out = capsys.readouterr().out - assert "Pre-install of esphome/bad @ 1.0 failed" in out - assert "Pre-install failed for 1 of 2 package(s)" in out - - -def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: - """Dependencies of wave-installed packages install in a second wave, - deduped by name; name-only platform libs stay with the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - cls.deps = { - "esphome/noise-c @ 0.1.21": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - {"name": "SPI"}, - ], - "esphome/wg @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"]) - assert len(cls.calls) == 3 # the shared dep installs exactly once - assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} - # Wave-1 strings carry no compatibility; the dependency wave does - compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.21"] is None - dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) - assert dep_compat is not None # mirrors pio's install_dependency - - -def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: - """A dependency pinned to a URL surfaces as spec.uri; it must stay out - of the wave like string URL specs do.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - cls.deps = { - "esphome/noise-c @ 0.1.21": [ - {"name": "vendored", "version": "https://github.com/x/y.git"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"]) - assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} - - -def test_failed_cleanup_fails_the_build(tmp_path: Path) -> None: - """A torn destination still on disk after rmtree must fail the build: - fs.rmtree never raises (its onexc handler prints), so only the - destination's absence proves the cleanup worked.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "torn-pkg" - torn.mkdir(parents=True) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - cls.get_package = get_package # throwaway subclass; nothing to restore - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed - pytest.raises(mod.CleanupError, match="could not remove"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert cls.lock_events == ["lock", "unlock"] # still released - - -def test_unverifiable_torn_destination_fails_the_build(tmp_path: Path) -> None: - """When the scan fails, the spec's own .piopm decides: an unremovable - leftover fails the build.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - dest = Path(cls.base_dir) / "packages" / "bad" - dest.mkdir(parents=True) - (dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - - def bad_reset(self): - raise OSError("scan broken") - - cls.memcache_reset = bad_reset - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed - pytest.raises(mod.CleanupError, match="could not remove"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_unverifiable_scan_without_leftover_degrades(tmp_path: Path, capsys) -> None: - """A failing scan with no destination on disk is never a build - failure blaming this spec.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - resets = {"n": 0} - - def bad_reset(self): - # Fail clean_torn's reset; the coordinator's later reset works - resets["n"] += 1 - if resets["n"] <= 1: - raise OSError("scan broken") - - cls.memcache_reset = bad_reset - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert "No resolvable destination to clean" in capsys.readouterr().out - - -def test_unresolvable_torn_destination_is_printed(tmp_path: Path, capsys) -> None: - """A failed install with no resolvable package prints, so an invisible - torn directory is at least traceable.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert "No resolvable destination to clean" in capsys.readouterr().out - - -def test_unparsable_torn_destination_is_removed(tmp_path: Path, capsys) -> None: - """A torn dir get_package cannot resolve but whose .piopm names the - spec is removed instead of surviving into the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - dest = Path(cls.base_dir) / "packages" / "bad" - dest.mkdir(parents=True) - (dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - - with patch.object(mod.fs, "rmtree", shutil.rmtree): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not dest.exists() - assert "Removed torn destination" in capsys.readouterr().out - - -def test_parse_specs_tools_branch(tmp_path: Path) -> None: - """platform_packages parsing keeps owner'd tools and rewrites github - URL pins to bare URLs the wave then skips via parsed.uri.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text( - "[env:t]\n" - "platform_packages =\n" - " ${common.platform_packages}\n" - " platformio/tool-scons@~4.40801.0\n" - " framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip\n" - ) - args = Namespace(libraries=False, platforms=False, tools=True) - libs, platforms, tools = mod.parse_specs(str(ini), args) - assert libs == [] and platforms == [] - assert tools == [ - "platformio/tool-scons@~4.40801.0", - "https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip", - ] - assert mod.build_cli_args([], [], tools)[:2] == ["-t", tools[0]] - - -def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: - """Already-installed top-level packages still feed the dependency - wave; a warm store can be missing a transitive dep.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"}) - cls.deps = { - "esphome/noise-c @ 0.1.21": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"]) - assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] - - -def test_worker_system_exit_still_cleans(tmp_path: Path, capsys) -> None: - """A worker SystemExit runs the torn cleanup before propagating; the - serial pass must never trust its leftovers.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - torn = tmp_path / "packages" / "torn-pkg" - torn.mkdir(parents=True) - - def exiting_install(self, spec, skip_dependencies, compatibility=None): - raise SystemExit(0) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - cls._install = exiting_install - cls.get_package = get_package - - def real_rmtree(path): - Path(path).rmdir() - - with ( - patch.object(mod.fs, "rmtree", real_rmtree), - pytest.raises(SystemExit), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - - -def test_unlock_failure_is_fatal(tmp_path: Path) -> None: - """A failed unlock must fail the build: the serial pass in another - process would block on the held flock.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - - def bad_unlock(self): - raise OSError("flock broke") - - cls.unlock = bad_unlock - with pytest.raises(mod.LockReleaseError, match="manager lock"): - mod.parallel_install(cls, ["esphome/good @ 1.0"]) - - -def test_unlock_failure_keeps_inflight_error_as_context(tmp_path: Path) -> None: - """An in-flight CleanupError stays attached when the unlock fault - takes over the raise.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "bad" - torn.mkdir(parents=True) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - def bad_unlock(self): - raise OSError("flock broke") - - cls.get_package = get_package - cls.unlock = bad_unlock - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # leaves torn - pytest.raises(mod.LockReleaseError) as err, - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert isinstance(err.value.__cause__.__context__, mod.CleanupError) - - -def test_chdir_failure_does_not_fail_the_wave(tmp_path: Path, monkeypatch) -> None: - """A lost cwd is suppressed: further waves may misbehave and fall to - the serial pass, whose cwd is pinned.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - monkeypatch.setattr(mod.os, "chdir", MagicMock(side_effect=OSError("gone"))) - mod.parallel_install(cls, ["esphome/good @ 1.0"]) - assert cls.calls == ["esphome/good @ 1.0"] - - -def test_piopm_match_removes_manifest_named_torn_dir(tmp_path: Path, capsys) -> None: - """A torn dir named by its manifest (not the registry spec) is found - through its .piopm and removed.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "ManifestName" - torn.mkdir(parents=True) - (torn / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - innocent = tmp_path / "packages" / "innocent" - innocent.mkdir() - (innocent / ".piopm").write_text('{"spec": {"owner": "o", "name": "other"}}') - with patch.object(mod.fs, "rmtree", shutil.rmtree): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - assert innocent.exists() # another package's valid metadata survives - assert "Removed torn destination" in capsys.readouterr().out - - -def test_unscannable_package_dir_fails_the_build(tmp_path: Path) -> None: - """A storage dir the cleanup cannot scan is not proof of cleanliness.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - real_iterdir = Path.iterdir - - def broken_iterdir(self): - if self.name == "packages": - raise PermissionError("denied") - return real_iterdir(self) - - with ( - patch.object(Path, "iterdir", broken_iterdir), - pytest.raises(mod.CleanupError, match="cleanup failed"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_stray_file_in_package_dir_is_ignored(tmp_path: Path) -> None: - """A plain file (or a pio-link) beside the packages is skipped by - pio's own scan and must never hard-fail the build.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - (tmp_path / "packages").mkdir(parents=True) - (tmp_path / "packages" / "stray.pio-link").write_text("x") - (tmp_path / "packages" / "no-metadata").mkdir() # pio overwrites these - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert (tmp_path / "packages" / "stray.pio-link").exists() - assert (tmp_path / "packages" / "no-metadata").exists() - - -def test_unreadable_piopm_dir_is_removed(tmp_path: Path) -> None: - """A persistently corrupt .piopm under this spec's own name would - crash pio's storage scan; the dir is removed rather than left to - break the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "bad" - torn.mkdir(parents=True) - (torn / ".piopm").write_text("{not json") - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - - -def test_unreadable_piopm_under_other_name_survives(tmp_path: Path) -> None: - """A corrupt .piopm in another package's dir may be a worker mid-copy; - a failing spec must not remove a directory it does not own.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - other = tmp_path / "packages" / "innocent" - other.mkdir(parents=True) - (other / ".piopm").write_text("{not json") - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert other.exists() - - -def test_unexpected_cleanup_class_becomes_cleanup_error(tmp_path: Path) -> None: - """Cleanup failures of any class fail the build; nothing may be - downgraded to the serial fallback over a torn directory.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - - with ( - patch.object( - mod, "piopm_matches", MagicMock(side_effect=ValueError("bad spec")) - ), - pytest.raises(mod.CleanupError, match="cleanup failed"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_main_cleanup_error_fails_before_generic_fallback(tmp_path: Path) -> None: - """A CleanupError must escape main's serial fallback: the clause order - decides whether a stuck torn package fails the image build.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n") - with ( - patch.object( - mod, "parallel_install", side_effect=mod.CleanupError("stuck torn pkg") - ), - patch.object(mod.subprocess, "check_call"), - patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]), - pytest.raises(mod.CleanupError), - ): - mod.main() - - -def test_main_generic_failure_still_runs_serial_pass(tmp_path: Path) -> None: - """A non-CleanupError wave failure prints, dumps the traceback, and - still reaches the authoritative serial pass with the pinned cwd.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n") - with ( - patch.object(mod, "parallel_install", side_effect=RuntimeError("boom")), - patch.object(mod.subprocess, "check_call") as mock_call, - patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]), - ): - mod.main() - mock_call.assert_called_once() - args, kwargs = mock_call.call_args - assert args[0][:4] == ["platformio", "pkg", "install", "-g"] - assert "esphome/x @ 1.0" in args[0] - assert kwargs["cwd"] == Path.cwd() - - -def test_content_cache_creates_its_dir(tmp_path: Path, monkeypatch) -> None: - """The cold-cache hardening relies on ContentCache.__init__ creating - the namespace dir; pin the side effect, not mere callability.""" - monkeypatch.setenv("PLATFORMIO_CACHE_DIR", str(tmp_path / "cache")) - ContentCache("http") - assert (tmp_path / "cache" / "http").is_dir() - - -def test_piopm_matches_without_name_matches_nothing(tmp_path: Path) -> None: - """A spec with no derivable name can never match a directory.""" - mod = _load_script() - assert mod.piopm_matches(str(tmp_path), "") == [] - - -def test_unresolvable_spec_stays_out_of_the_wave(tmp_path: Path, capsys) -> None: - """A spec with no derivable name is left to the serial pass; a raw - string key would break the one-per-destination dedupe.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - nameless = PackageSpec(requirements="^1.0") - mod.parallel_install(cls, [nameless]) - assert cls.calls == [] - assert "Skipping unresolvable spec" in capsys.readouterr().out - - -def test_parallel_install_unlocks_when_pool_fails(tmp_path: Path) -> None: - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - with ( - patch.object(mod, "ThreadPoolExecutor", side_effect=RuntimeError("no")), - pytest.raises(RuntimeError), - ): - mod.parallel_install(cls, ["esphome/a @ 1.0"]) - assert cls.lock_events == ["lock", "unlock"] - - -def test_parse_specs_unreadable_ini_fails_loudly(tmp_path: Path) -> None: - """A bad path must not silently build an image with no dependencies.""" - mod = _load_script() - args = Namespace(libraries=True, platforms=False, tools=False) - with pytest.raises(SystemExit): - mod.parse_specs(str(tmp_path / "missing.ini"), args) - - -def test_platformio_surface_for_install_deps_script() -> None: - """A PlatformIO bump that changes these members must fail here, not - silently turn the docker image's parallel preinstall into a no-op.""" - # The script calls these positionally; pin the positions, not just - # membership, so a parameter reorder trips the wire too - params = inspect.signature(PackageManagerInstallMixin._install).parameters - assert list(params)[1] == "spec" - assert "skip_dependencies" in params - assert "compatibility" in params - for cls in (ToolPackageManager, LibraryPackageManager): - assert list(inspect.signature(cls.__init__).parameters)[1] == "package_dir" - for name in ( - "lock", - "unlock", - "get_package", - "memcache_reset", - "get_pkg_dependencies", - "dependency_to_spec", - "get_download_dir", - "get_tmp_dir", - ): - assert callable(getattr(BasePackageManager, name)) - # Losing any of these turns the wave into main()'s silent serial - # fallback: ensure_spec runs in the coordinator, the spec attributes - # feed the dedupe, cleanup, and dependency filters - assert callable(BasePackageManager.ensure_spec) - spec = PackageSpec("owner/name @ ^1.0") - assert spec.name == "name" - assert spec.owner == "owner" - assert spec.uri is None - assert spec.external is False - assert Version("1.5.0") in spec.requirements - # The failure-cleanup path degrades to a single line if these vanish - assert callable(fs.rmtree) - assert callable(fs.load_json) - # piopm_matches only tolerates a corrupt .piopm through this base; - # losing it would flip a wave failure from degrade to build failure - assert issubclass(InvalidJSONFile, ValueError) - assert PackageItem("pkg-dir").path == "pkg-dir" - assert callable(PackageCompatibility.from_dependency) diff --git a/tests/script/test_setup.py b/tests/script/test_setup.py new file mode 100644 index 00000000000..3e816c4b05d --- /dev/null +++ b/tests/script/test_setup.py @@ -0,0 +1,562 @@ +"""Tests for script/setup.py.""" + +import importlib.util +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import runpy +import sys +from types import ModuleType +from unittest.mock import Mock, call, patch + +import pytest + +_SCRIPT = Path(__file__).parents[2] / "script" / "setup.py" + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("script_setup", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def script_setup() -> ModuleType: + """Fresh import of script/setup.py, isolated from other tests.""" + return _load_module() + + +# --- bin_dir / venv_python / activate_hint ----------------------------------- + + +def test_bin_dir_matches_host_layout(script_setup: ModuleType, tmp_path: Path) -> None: + """The venv scheme resolves to Scripts on Windows and bin everywhere else.""" + expected = "Scripts" if os.name == "nt" else "bin" + assert script_setup.bin_dir(tmp_path) == tmp_path / expected + + +# Both flavours are exercised on every host. Pure paths are used because a real +# Path refuses to change flavour: PosixPath cannot be built on Windows, and +# WindowsPath cannot be built on Unix. + + +def test_venv_python_posix(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PurePosixPath("/x/venv/bin/python") + + +def test_venv_python_nt(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PureWindowsPath(r"C:\x\venv\Scripts\python.exe") + + +def test_activate_hint_posix(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PurePosixPath("/x")), + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + hint = script_setup.activate_hint() + assert hint == "source venv/bin/activate" + + +def test_activate_hint_nt(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PureWindowsPath(r"C:\x")), + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + hint = script_setup.activate_hint() + # The nt branch returns str(activate) as-is, skipping the "source " prefix. + assert hint == r"venv\Scripts\activate" + + +# --- run ----------------------------------------------------------------- + + +def test_run_success(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run: + script_setup.run(["echo", "hi"]) + mock_run.assert_called_once_with( + ["echo", "hi"], cwd=script_setup.ROOT, env=None, check=False + ) + + +def test_run_failure_raises_system_exit_with_code( + script_setup: ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=7)), + pytest.raises(SystemExit) as excinfo, + ): + script_setup.run(["false"]) + assert excinfo.value.code == 7 + assert "Failed with exit code 7: false" in capsys.readouterr().err + + +# --- git_output ------------------------------------------------------------ + + +def test_git_output_success_strips_stdout(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, + "run", + return_value=Mock(returncode=0, stdout=" /repo/.git \n"), + ) as mock_run: + result = script_setup.git_output("rev-parse", "--absolute-git-dir") + assert result == "/repo/.git" + mock_run.assert_called_once_with( + ["git", "rev-parse", "--absolute-git-dir"], + cwd=script_setup.ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_git_output_nonzero_returncode_is_empty(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, + "run", + return_value=Mock(returncode=1, stdout="whatever"), + ): + assert script_setup.git_output("status") == "" + + +def test_git_output_oserror_is_empty(script_setup: ModuleType) -> None: + with patch.object(script_setup.subprocess, "run", side_effect=OSError("no git")): + assert script_setup.git_output("status") == "" + + +# --- create_venv ----------------------------------------------------------- + + +def test_create_venv_uses_uv_when_present( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.create_venv(venv) + mock_run.assert_called_once_with( + ["/usr/bin/uv", "venv", "--clear", "--seed", str(venv)], + cwd=script_setup.ROOT, + env=None, + check=False, + ) + + +def test_create_venv_falls_back_to_venv_module( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + with ( + patch.object(script_setup.shutil, "which", return_value=None), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.create_venv(venv) + mock_run.assert_called_once_with( + [sys.executable, "-m", "venv", "--clear", str(venv)], + cwd=script_setup.ROOT, + env=None, + check=False, + ) + + +# --- venv_environment -------------------------------------------------------- + + +def test_venv_environment_sets_virtual_env_and_prepends_path( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + venv = tmp_path / "venv" + monkeypatch.setenv("PYTHONHOME", "/somewhere") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + env = script_setup.venv_environment(venv) + assert env["VIRTUAL_ENV"] == str(venv) + assert "PYTHONHOME" not in env + expected_prefix = str(script_setup.bin_dir(venv)) + os.pathsep + assert env["PATH"] == expected_prefix + "/usr/bin:/bin" + + +def test_venv_environment_path_fallback_when_unset( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + venv = tmp_path / "venv" + monkeypatch.delenv("PATH", raising=False) + env = script_setup.venv_environment(venv) + # No trailing separator: an empty PATH entry means "search the cwd". + assert env["PATH"] == str(script_setup.bin_dir(venv)) + + +# --- find_uv ----------------------------------------------------------------- + + +def test_find_uv_found_immediately(script_setup: ModuleType, tmp_path: Path) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + result = script_setup.find_uv(venv, env) + assert result == "/usr/bin/uv" + mock_run.assert_not_called() + + +def test_find_uv_installed_then_found(script_setup: ModuleType, tmp_path: Path) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", side_effect=[None, "/usr/bin/uv"]), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + result = script_setup.find_uv(venv, env) + assert result == "/usr/bin/uv" + mock_run.assert_called_once_with( + [str(script_setup.venv_python(venv)), "-m", "pip", "install", "uv"], + cwd=script_setup.ROOT, + env=env, + check=False, + ) + + +def test_find_uv_still_missing_raises_system_exit( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", side_effect=[None, None]), + patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=0)), + pytest.raises(SystemExit, match="uv could not be installed"), + ): + script_setup.find_uv(venv, env) + + +# --- install_dependencies ----------------------------------------------------- + + +def test_install_dependencies_installs_setuptools_then_project( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_dependencies(venv, env) + assert mock_run.call_args_list == [ + call( + ["/usr/bin/uv", "pip", "install", "setuptools", "wheel"], + cwd=script_setup.ROOT, + env=env, + check=False, + ), + call( + [ + "/usr/bin/uv", + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + cwd=script_setup.ROOT, + env=env, + check=False, + ), + ] + + +# --- install_git_hooks --------------------------------------------------------- + + +def _fake_git_output(git_dir: str, common_dir: str): + def _run(*args: str) -> str: + if "--absolute-git-dir" in args: + return git_dir + return common_dir + + return _run + + +def test_install_git_hooks_returns_early_when_git_dir_empty( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, "git_output", side_effect=_fake_git_output("", "/repo/.git") + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_returns_early_when_common_dir_empty( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, "git_output", side_effect=_fake_git_output("/repo/.git", "") + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_returns_early_for_worktree( + script_setup: ModuleType, +) -> None: + """A worktree's git-dir differs from the shared common-dir.""" + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output("/repo/.git/worktrees/wt", "/repo/.git"), + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_missing_prek_raises_system_exit( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output("/repo/.git", "/repo/.git"), + ), + patch.object(script_setup.shutil, "which", return_value=None), + patch.object(script_setup.subprocess, "run") as mock_run, + pytest.raises(SystemExit, match="prek was not installed"), + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_happy_path_installs_hook( + script_setup: ModuleType, tmp_path: Path +) -> None: + env = {"PATH": "/usr/bin"} + common_dir = tmp_path / "repo" / ".git" + hooks_dir = common_dir / "hooks" + hooks_dir.mkdir(parents=True) + source_hook = tmp_path / "post-checkout" + source_hook.write_text("#!/bin/sh\necho post-checkout\n") + + with ( + patch.object(script_setup, "POST_CHECKOUT_HOOK", source_hook), + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output(str(common_dir), str(common_dir)), + ), + patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_git_hooks(env) + + mock_run.assert_called_once_with( + ["/usr/bin/prek", "install", "--overwrite"], + cwd=script_setup.ROOT, + env=env, + check=False, + ) + installed = hooks_dir / "post-checkout" + assert installed.read_text() == source_hook.read_text() + if os.name != "nt": + # Windows has no POSIX permission bits for chmod to set. + assert (installed.stat().st_mode & 0o777) == 0o755 + + +def test_install_git_hooks_skips_copy_when_hooks_dir_missing( + script_setup: ModuleType, tmp_path: Path +) -> None: + """The prek install still runs when the hooks directory does not exist.""" + env = {"PATH": "/usr/bin"} + common_dir = tmp_path / "repo" / ".git" + common_dir.mkdir(parents=True) # no "hooks" subdirectory created + + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output(str(common_dir), str(common_dir)), + ), + patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_git_hooks(env) + + mock_run.assert_called_once() + assert not (common_dir / "hooks").exists() + + +# --- report ------------------------------------------------------------------ + + +def test_report_active_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + venv = Path("/opt/esphome-venv") + script_setup.report(script_setup.VENV_ACTIVE, venv) + out = capsys.readouterr().out + assert "Dependencies installed into the active virtual environment:" in out + assert str(venv) in out + assert "is already active in this shell" in out + + +def test_report_reused_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + script_setup.report(script_setup.VENV_REUSED, script_setup.DEFAULT_VENV) + out = capsys.readouterr().out + assert "Dependencies updated in the existing ./venv" in out + + +def test_report_created_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + script_setup.report(script_setup.VENV_CREATED, script_setup.DEFAULT_VENV) + out = capsys.readouterr().out + assert "Virtual environment created at ./venv" in out + + +# --- main -------------------------------------------------------------------- + + +def test_main_raises_system_exit_when_python_too_old( + script_setup: ModuleType, +) -> None: + with ( + patch.object(script_setup.sys, "version_info", (3, 11, 5)), + pytest.raises(SystemExit, match="ESPHome needs Python 3.12"), + ): + script_setup.main() + + +def test_main_uses_active_virtual_env( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + active_venv = tmp_path / "active-venv" + monkeypatch.setenv("VIRTUAL_ENV", str(active_venv)) + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_not_called() + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_ACTIVE, active_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_main_reuses_existing_venv( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + default_venv = tmp_path / "venv" + python_path = script_setup.venv_python(default_venv) + python_path.parent.mkdir(parents=True) + python_path.touch() + + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "DEFAULT_VENV", default_venv), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_not_called() + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_REUSED, default_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_main_creates_new_venv( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + default_venv = tmp_path / "venv" # does not exist yet + + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "DEFAULT_VENV", default_venv), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_called_once_with(default_venv) + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_CREATED, default_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_run_as_script_calls_main(tmp_path: Path) -> None: + """The __main__ guard runs the whole flow, with every side effect stubbed.""" + completed = Mock(returncode=0, stdout="") + with ( + patch("subprocess.run", return_value=completed) as mock_run, + patch("shutil.which", return_value="/usr/bin/uv"), + patch("pathlib.Path.mkdir") as mock_mkdir, + patch.dict(os.environ, {"VIRTUAL_ENV": str(tmp_path / "env")}), + ): + runpy.run_path(str(_SCRIPT), run_name="__main__") + + # The dependency install ran, and git reported no hooks directory to touch. + assert mock_run.called + mock_mkdir.assert_called_once_with(exist_ok=True) diff --git a/tests/script/test_sync_dependency_versions.py b/tests/script/test_sync_dependency_versions.py new file mode 100644 index 00000000000..787c8112d9d --- /dev/null +++ b/tests/script/test_sync_dependency_versions.py @@ -0,0 +1,219 @@ +"""Unit tests for script/sync_dependency_versions.py.""" + +from pathlib import Path +import subprocess +import sys + +import pytest +import yamlrocks + +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import sync_dependency_versions as sync_mod # noqa: E402 + +PRECOMMIT = """\ +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.0 + hooks: + - id: ruff + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + - repo: https://github.com/asottile/pyupgrade + rev: v3.0.0 + hooks: + - id: pyupgrade + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v13.0.1 + hooks: + - id: clang-format + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.0.0 + hooks: + - id: yamllint + - repo: local + hooks: + - id: pylint +""" + +REQ_TEST = """\ +pylint==4.0.8 +flake8==7.1.0 +ruff==0.2.0 # comment +pyupgrade==3.0.0 +""" + +REQ_DEV = """\ +clang-format==13.0.1 +yamllint==1.0.0 +""" + +RUFF_REPO = "https://github.com/astral-sh/ruff-pre-commit" +DUPLICATE_RUFF_BLOCK = f" - repo: {RUFF_REPO}\n rev: v0.3.0\n hooks: []\n" + +EXPECTED_DRIFT = ["ruff: 0.1.0 -> 0.2.0", "flake8: 7.0.0 -> 7.1.0"] +EXPECTED_PRECOMMIT = PRECOMMIT.replace("rev: v0.1.0", "rev: v0.2.0").replace( + "rev: 7.0.0", "rev: 7.1.0" +) + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + """A fake checkout where ruff (v-prefixed) and flake8 (bare) have drifted.""" + (tmp_path / ".pre-commit-config.yaml").write_text(PRECOMMIT) + (tmp_path / "requirements_test.txt").write_text(REQ_TEST) + (tmp_path / "requirements_dev.txt").write_text(REQ_DEV) + return tmp_path + + +def _load(text: str) -> object: + return yamlrocks.loads(text.encode(), option=yamlrocks.OPT_ROUND_TRIP) + + +@pytest.mark.parametrize( + ("requirements", "expected"), + [ + ("prek==0.5.1 # comment\n", "0.5.1"), + ("Prek==0.5.1\n", "0.5.1"), + ("other==1.0\nprek==0.5.1\n", "0.5.1"), + ("prek>=0.5.1\n", None), + ("prek-extra==0.5.1\n", None), + ("", None), + ], +) +def test_read_requirement_version(requirements: str, expected: str | None) -> None: + assert sync_mod.read_requirement_version(requirements, "prek") == expected + + +def test_find_repo_entry() -> None: + entry = sync_mod.find_repo_entry(_load(PRECOMMIT), RUFF_REPO) + assert entry["rev"] == "v0.1.0" + + +@pytest.mark.parametrize( + ("text", "message"), + [ + ("hooks: []\n", "missing key 'repos'"), + ("repos:\n - rev: 1.0.0\n", "missing key 'repo'"), + (PRECOMMIT + DUPLICATE_RUFF_BLOCK, "found 2"), + ("repos:\n - repo: other\n rev: 1.0.0\n", "found 0"), + ], +) +def test_find_repo_entry_errors(text: str, message: str) -> None: + with pytest.raises(sync_mod.SyncError, match=message): + sync_mod.find_repo_entry(_load(text), RUFF_REPO) + + +@pytest.mark.parametrize( + ("rev", "expected"), + [("v0.1.0", ("v", "0.1.0")), ("7.0.0", ("", "7.0.0")), ("'1.0'", ("", "1.0"))], +) +def test_current_rev(rev: str, expected: tuple[str, str]) -> None: + doc = _load(f"repos:\n - repo: {RUFF_REPO}\n rev: {rev}\n") + assert sync_mod.current_rev(doc["repos"][0], RUFF_REPO) == expected + + +@pytest.mark.parametrize( + ("block", "message"), + [(" hooks: []\n", "has no rev"), (" rev: 1.0\n", "not a string: 1.0")], +) +def test_current_rev_errors(block: str, message: str) -> None: + doc = _load(f"repos:\n - repo: {RUFF_REPO}\n{block}") + with pytest.raises(sync_mod.SyncError, match=message): + sync_mod.current_rev(doc["repos"][0], RUFF_REPO) + + +def test_sync_reports_without_writing(root: Path) -> None: + assert sync_mod.sync(root, write=False) == EXPECTED_DRIFT + assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT + + +def test_sync_writes_keeps_layout_and_is_idempotent(root: Path) -> None: + assert sync_mod.sync(root, write=True) == EXPECTED_DRIFT + assert (root / ".pre-commit-config.yaml").read_text() == EXPECTED_PRECOMMIT + assert sync_mod.sync(root, write=True) == [] + + +def test_sync_does_not_touch_a_config_that_matches(root: Path) -> None: + (root / ".pre-commit-config.yaml").write_text(EXPECTED_PRECOMMIT) + before = (root / ".pre-commit-config.yaml").stat().st_mtime_ns + assert sync_mod.sync(root, write=True) == [] + assert (root / ".pre-commit-config.yaml").stat().st_mtime_ns == before + + +def test_sync_missing_requirement_pin(root: Path) -> None: + (root / "requirements_dev.txt").write_text("") + with pytest.raises(sync_mod.SyncError, match="no 'clang-format==' pin"): + sync_mod.sync(root, write=True) + + +def test_sync_propagates_config_errors(root: Path) -> None: + (root / ".pre-commit-config.yaml").write_text(PRECOMMIT + DUPLICATE_RUFF_BLOCK) + with pytest.raises(sync_mod.SyncError, match="found 2"): + sync_mod.sync(root, write=True) + + +def test_main_check_reports_drift( + root: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert sync_mod.main(["--check", "--root", str(root)]) == 1 + assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT + assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT + + +def test_main_writes_then_check_is_clean( + root: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert sync_mod.main(["--root", str(root)]) == 0 + assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT + assert sync_mod.main(["--check", "--root", str(root)]) == 0 + assert capsys.readouterr().out == "" + + +def test_main_reports_sync_error( + root: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (root / "requirements_dev.txt").write_text("") + assert sync_mod.main(["--root", str(root)]) == 1 + assert ( + "error: requirements_dev.txt: no 'clang-format==' pin" + in capsys.readouterr().err + ) + + +def test_main_defaults_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + + def fake_sync(root: Path, *, write: bool) -> list[str]: + seen["root"] = root + seen["write"] = write + return [] + + monkeypatch.setattr(sync_mod, "sync", fake_sync) + assert sync_mod.main([]) == 0 + assert seen == {"root": sync_mod.REPO_ROOT, "write": True} + + +def test_repository_is_in_sync() -> None: + """The real checkout must match; a failure here means a rev has drifted. + + Also proves every SYNC_TARGETS entry still resolves in the real files. + """ + assert sync_mod.sync(sync_mod.REPO_ROOT, write=False) == [] + + +def test_cli_entry_point(root: Path) -> None: + """Run the script the way the workflow does, as a subprocess.""" + script = Path(sync_mod.__file__) + result = subprocess.run( + [sys.executable, str(script), "--check", "--root", str(root)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 + assert result.stdout.splitlines() == EXPECTED_DRIFT diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py index 74e150380c5..1d21e5d943e 100644 --- a/tests/script/test_test_build_components.py +++ b/tests/script/test_test_build_components.py @@ -236,3 +236,98 @@ def test_run_grouped_test_closes_group_when_subprocess_raises( ) assert "::endgroup::" in capsys.readouterr().out + + +def test_components_empty_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """Under --fail-on-no-tests, a real component filtered to a platform + with no matching test file must not pass CI as a green zero-component + compile.""" + rc = tbc.test_components( + ["logger"], + "zz-none", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out) + + +def test_components_component_with_no_base_file_fails_with_flag( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A component whose fixture matches the platform but whose platform has + no base file builds nothing; under the flag that silent zero fails by + component name instead of hiding behind other components.""" + monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {}) + rc = tbc.test_components( + ["logger"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out) + + +def test_components_blank_list_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A fully blank component list must not slide into the baseline + fallback and exit green under the flag.""" + rc = tbc.test_components( + [""], "esp8266-ard", "compile", False, fail_on_no_tests=True + ) + assert rc == 1 + assert "blank component list" in capsys.readouterr().out + + +def test_components_wildcard_no_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A wildcard matching nothing must not degrade to the synthetic + baseline build and exit green under the flag.""" + rc = tbc.test_components( + ["zz_no_such*"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out + + +def test_components_empty_match_tolerated_without_flag() -> None: + """The esp32-ard smoke leg deliberately builds only the subset with a + matching fixture; without the flag an empty match stays green.""" + assert ( + tbc.test_components( + ["logger"], "zz-none", "compile", False, enable_grouping=False + ) + == 0 + ) + + +def test_components_unknown_component_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A renamed smoke-test component must shrink coverage loudly, not fall + into the reference-baseline build.""" + rc = tbc.test_components( + ["no_such_component_xyz"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out diff --git a/tests/test_build_components/build_components_base.esp8266-ard.yaml b/tests/test_build_components/build_components_base.esp8266-ard.yaml index 1e2d6143921..a20aa573aec 100644 --- a/tests/test_build_components/build_components_base.esp8266-ard.yaml +++ b/tests/test_build_components/build_components_base.esp8266-ard.yaml @@ -4,6 +4,7 @@ esphome: esp8266: board: d1_mini_pro + toolchain: arduino logger: level: VERY_VERBOSE diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py new file mode 100644 index 00000000000..4a0355fd010 --- /dev/null +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -0,0 +1,1845 @@ +"""Drift tests for the native ESP8266 Arduino build generator. + +Pin the transliterated flag/define/link sets against literals audited from +the PlatformIO builder. Upstream drift is caught by the A/B build check on +version bumps, not here. +""" + +from __future__ import annotations + +from collections.abc import Generator +import logging +import os +from pathlib import Path +import shutil +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.arduino.library import ArduinoLibrary +from esphome.arduino8266.framework import InstalledPaths, toolchain_tool +from esphome.build_gen import arduino8266 +from esphome.build_gen.arduino8266 import ( + _defines_flags, + _flag_defines, + _flash_size_str, + _resolve_build_config, + get_flash_ld_path, +) +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD +from esphome.components.esp8266.build_surgery import RATETABLE_RULE +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _setup_core(tmp_path: Path) -> Generator[None]: + CORE.name = "test8266" + CORE.build_path = tmp_path + CORE.testing_mode = False + CORE.cpp_standard = "gnu++20" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} + CORE.data[KEY_ESP8266] = { + KEY_BOARD: "nodemcuv2", + KEY_SCANF_FLOAT: False, + } + # The producers esp8266/__init__ pins unconditionally + CORE.platformio_options = { + "board_build.flash_mode": "dout", + "build_src_flags": "-include esphome/components/esp8266/throw_stubs.h", + } + yield + # CORE.reset() (the suite-wide autouse fixture) does not clear this flag + CORE.testing_mode = False + + +def _set_flags(*flags: str) -> None: + CORE.build_flags = set(flags) + + +def _shq(tok: str) -> str: + """The platform's shell_token quote wrapper (argv rule on Windows).""" + return f'"{tok}"' if os.name == "nt" else f"'{tok}'" + + +def _resolve(*flags: str) -> arduino8266._BuildConfig: + """Set the build flags and resolve the knob config in one step.""" + _set_flags(*flags) + return _resolve_current() + + +def _defines() -> dict[str, str]: + """The -D map for the current build flags.""" + return _flag_defines(set(), arduino8266._lexed_build_flags()) + + +def _resolve_current() -> arduino8266._BuildConfig: + """Resolve whatever flags are already set (must not clear them).""" + return _resolve_build_config(_defines()) + + +def _split_flags() -> tuple[list[str], list[str], list[Path], list[str]]: + """Classify the current build flags the way write_project does.""" + return arduino8266._project_flags( + arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() + ) + + +def _ok_result( + stdout: str | bytes | None = None, stderr: str | bytes = "" +) -> MagicMock: + """A successful preprocessor spawn (defaults to the common ld output). + + Streams are bytes, as the un-decoded subprocess.run delivers them. + """ + stdout = _COMMON_LD_H_OUTPUT if stdout is None else stdout + return MagicMock( + returncode=0, + stdout=stdout.encode() if isinstance(stdout, str) else stdout, + stderr=stderr.encode() if isinstance(stderr, str) else stderr, + ) + + +def test_build_config_defaults() -> None: + + config = _resolve() + assert config.nonosdk == "NONOSDK22x_190703" + assert config.lwip_lib == "lwip2-536-feat" + assert not config.exceptions + assert config.vtables == "VTABLES_IN_FLASH" + assert config.knob_defines == [ + "NONOSDK22x_190703=1", + "TCP_MSS=536", + "LWIP_FEATURES=1", + "LWIP_IPV6=0", + ] + assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"] + + +def test_build_config_esphome_lwip_knob() -> None: + """The lwIP variant ESPHome selects maps to the same defines and library + as the PlatformIO builder.""" + + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert config.lwip_lib == "lwip2-1460" + assert "TCP_MSS=1460" in config.knob_defines + assert "LWIP_FEATURES=0" in config.knob_defines + assert "LWIP_IPV6=0" in config.knob_defines + + +def test_build_config_knobs() -> None: + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DVTABLES_IN_DRAM", + ) + config = _resolve_current() + assert config.nonosdk == "NONOSDK305" + assert config.exceptions + assert config.vtables == "VTABLES_IN_DRAM" + assert config.mmu_defines == ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"] + + +def test_build_config_mmu_custom_requires_sizes() -> None: + + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM") + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + config = _resolve_current() + # Emitted pre-sorted so build.ninja stays byte-stable across runs + assert config.mmu_defines == [ + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_SIZE=0xC000", + ] + + +def test_defines_match_platformio_builder() -> None: + """The exact define set the PlatformIO builder passes for nodemcuv2/dout.""" + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert _defines_flags( + _resolve_current(), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) == [ + "-DF_CPU=80000000L", + "-D__ets__", + "-DICACHE_FLASH", + "-D_GNU_SOURCE", + "-DARDUINO=10805", + '-DARDUINO_BOARD=\\"PLATFORMIO_NODEMCUV2\\"', + '-DARDUINO_BOARD_ID=\\"nodemcuv2\\"', + "-DFLASHMODE_DOUT", + "-DLWIP_OPEN_SRC", + "-DNONOSDK22x_190703=1", + "-DTCP_MSS=1460", + "-DLWIP_FEATURES=0", + "-DLWIP_IPV6=0", + "-DVTABLES_IN_FLASH", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DESP8266", + "-DARDUINO_ARCH_ESP8266", + "-DARDUINO_ESP8266_NODEMCU_ESP12E", + ] + + +def _make_framework(tmp_path: Path) -> InstalledPaths: + framework = tmp_path / "framework" + core = framework / "cores" / "esp8266" + core.mkdir(parents=True) + for name in ( + "core_esp8266_main.cpp", + "Updater.cpp", + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", + "cont.S", + "abi.c", + ): + (core / name).write_text("") + (framework / "variants" / "nodemcu").mkdir(parents=True) + for sub in ("include", "ld", "lwip2/include", "lib"): + (framework / "tools" / "sdk" / sub).mkdir(parents=True) + (framework / "libraries").mkdir() + (framework / "tools" / "elf2bin.py").write_text("") + eboot = framework / "bootloaders" / "eboot" + eboot.mkdir(parents=True) + (eboot / "eboot.elf").write_text("") + toolchain = tmp_path / "toolchain" + (toolchain / "bin").mkdir(parents=True) + (toolchain / "include").mkdir() + return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja")) + + +def _write_ninja( + paths: InstalledPaths, + libraries: list | None = None, + ccache: str | None = None, +) -> str: + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + (src / "esphome" / "vendor.c").write_text("") + + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch( + "esphome.arduino.library.resolve_libraries", + return_value=libraries or [], + ), + ): + arduino8266.write_project(paths, ccache) + return (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text() + + +def test_write_project_rejects_bad_flash_mode(tmp_path: Path) -> None: + """A flash mode outside the closed set fails by name before landing + unquoted in the elf2bin command.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dout; rm -rf /" + with pytest.raises(EsphomeError, match="Invalid flash mode"): + _write_ninja(paths) + + +def test_write_project_flash_mode_reaches_define_and_elf2bin( + tmp_path: Path, +) -> None: + """A non-default board_flash_mode lands in both the compile define and + the elf2bin image header, never silently falling back to dout.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dio" + content = _write_ninja(paths) + assert "-DFLASHMODE_DIO" in content + assert "--flash_mode dio" in content + assert "FLASHMODE_DOUT" not in content + + +def test_write_project_trailing_include_raises(tmp_path: Path) -> None: + """A dangling -include must fail by name, not become -include .""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = "-include" + with pytest.raises(EsphomeError, match="trailing '-include'"): + _write_ninja(paths) + + +def test_write_project_passes_other_src_flags_through(tmp_path: Path) -> None: + """Non-include build_src_flags tokens are requoted onto the src edges.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = ( + "-include esphome/components/esp8266/throw_stubs.h -DSRC_ONLY=1" + ) + content = _write_ninja(paths) + assert "throw_stubs.h" in content + assert "-DSRC_ONLY=1" in content + + +def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + "-DUSE_ESP8266_WAVEFORM_STUBS", + "-Wl,--wrap=millis", + "-Wl,--wrap=printf", + "-Wno-nonnull-compare", + "-L/opt/blobs", + "-luser_blob", + "-L /spc/blobs -l spaced_blob", + ) + content = _write_ninja(paths) + + # Base link flags from the PlatformIO builder + for flag in ( + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u app_entry", + "-u _printf_float", + "-u _DebugExceptionVector", + "-u _DoubleExceptionVector", + "-u _KernelExceptionVector", + "-u _NMIExceptionVector", + "-u _UserExceptionVector", + ): + assert flag in content + # ESPHome's link flags and the board linker script + assert "-Wl,--wrap=millis" in content + assert "-Wl,--wrap=printf" in content + assert "-T eagle.flash.4m.ld" in content + # scanf float disabled: the forced-link flag must not appear + assert "_scanf_float" not in content + # $in/$out must stay unquoted; ninja escapes its own path variables + assert "-c $in -o $out" in content + assert "--app $in --flash_mode" in content + assert '"$in"' not in content + assert '"$out"' not in content + # -L/-l from esphome build_flags reach the link line, not the compiles; + # spaced forms ("-L /path") are shell-lexed the way PlatformIO does. + # str(Path(...)) so the separator matches the host platform. + opt_blobs = str(Path("/opt/blobs")) + spc_blobs = str(Path("/spc/blobs")) + assert f"-L{_shq(opt_blobs)}" in content + assert "-luser_blob" in content + assert f"-L{_shq(spc_blobs)}" in content + assert "-lspaced_blob" in content + for line in content.splitlines(): + if line.split(" = ")[0] in ("cflags", "cxxflags", "asflags"): + assert "user_blob" not in line + assert opt_blobs not in line + assert "spaced_blob" not in line + assert spc_blobs not in line + # System libraries with the selected lwIP variant, in the builder's order + assert ( + "-lhal -lphy -lpp -lnet80211 -llwip2-1460 -lwpa -lcrypto -lmain -lwps " + "-lbearssl -lespnow -lsmartconfig -lairkiss -lwpa2 -lspaced_blob " + "-luser_blob " + "-lstdc++ -lm -lc -lgcc" in content + ) + # Core exclusions: native OTA backend and waveform stubs + assert "Updater.cpp" not in content + assert "core_esp8266_waveform_pwm.cpp" not in content + assert "core_esp8266_waveform_phase.cpp" not in content + assert "core_esp8266_main.cpp.o" in content + # Assembly and C sources compile through their own rules + assert "cont.S.o: aspp" in content + assert "abi.c.o: c" in content + # throw_stubs is force-included for ESPHome sources only, via one shared + # srcflags variable rather than a copy of the flags line per edge + src_lines = [line for line in content.splitlines() if "obj/src/" in line] + assert any("main.cpp.o: cxx" in line for line in src_lines) + assert content.count("throw_stubs.h") == 1 + assert "srcflags = -include" in content + flags_lines = [ + line for line in content.splitlines() if line.startswith(" flags = ") + ] + assert flags_lines + # C++ src edges consume the precompiled header; C/assembly keep srcflags + assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"} + + +def test_write_project_pch(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths, ccache="/usr/bin/ccache") + build_dir = CORE.relative_pioenvs_path(CORE.name) + assert "rule pch" in content + # Compiled from the include list, and again when the checksum changes + gch_edge = next( + line + for line in content.splitlines() + if line.startswith("build esphome_pch.h.gch: pch ") + ) + assert gch_edge.endswith("esphome_pch_src.h | esphome_pch.h.gch.sum") + for line in content.splitlines(): + # C++ edges wait on the .gch; the C edge must not reference it + if line.startswith("build obj/src/main.cpp.o:"): + assert line.endswith("| esphome_pch.h.gch") + if line.startswith("build obj/src/esphome/vendor.c.o:"): + assert "esphome_pch" not in line + assert (build_dir / "esphome_pch_src.h").read_text().splitlines() == [ + '#include "esphome/components/esp8266/throw_stubs.h"', + '#include "esphome/core/pch_prefix.h"', + ] + assert "#error" in (build_dir / "esphome_pch.h").read_text() + assert (build_dir / "esphome_pch.h.gch.sum").read_text().strip() + + +def test_write_project_pch_folds_joined_src_force_include( + tmp_path: Path, +) -> None: + """-includefoo.h in build_src_flags must fold into the pch like the + separated spelling, not precede and defeat it.""" + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + CORE.platformio_options["build_src_flags"] = "-includeesphome/core/defines.h" + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "build esphome_pch.h.gch: pch" in content + assert "srccxxflags" in content + assert "-includeesphome" not in content + + +@pytest.mark.parametrize("flag", ["-includefoo.h", "--include=foo.h"]) +def test_write_project_pch_skipped_for_other_force_include_spellings( + tmp_path: Path, caplog: pytest.LogCaptureFixture, flag: str +) -> None: + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", flag) + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "esphome_pch" not in content + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_skipped_when_user_force_include_precedes( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A -include in build_flags lands ahead of the pch include, so GCC + would never load the .gch; skip it and say so.""" + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h" + ) + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "esphome_pch" not in content + assert "srccxxflags" not in content + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths) + assert "esphome_pch" not in content + assert "srccxxflags" not in content + assert " flags = $srcflags" in content + + +def test_write_project_pch_asks_the_toolchain_compiler_on_windows( + windows_gcc_rule: None, tmp_path: Path +) -> None: + from esphome.build_helpers import pch + + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + with patch.object(pch, "gcc_version", return_value=(10, 3, 0)) as asked: + content = _write_ninja(paths) + assert asked.call_args.args[0] == (toolchain_tool(paths.toolchain, "g++"),) + assert "esphome_pch" not in content + + +def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths) + assert "-u _scanf_float" in content + # Waveform not stubbed out: both implementations stay in the archive + assert "core_esp8266_waveform_pwm.cpp.o" in content + assert "core_esp8266_waveform_phase.cpp.o" in content + + +@pytest.mark.parametrize( + ("knob", "lib", "mss", "features", "ipv6"), + [ + ("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", "lwip6-536-feat", 536, 1, 1), + ( + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH", + "lwip6-1460-feat", + 1460, + 1, + 1, + ), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", "lwip2-1460-feat", 1460, 1, 0), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", "lwip2-536", 536, 0, 0), + # LOW_MEMORY has no upstream branch: it is the default (else) variant + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", "lwip2-536-feat", 536, 1, 0), + ], +) +def test_build_config_lwip_variants( + knob: str, lib: str, mss: int, features: int, ipv6: int +) -> None: + """Every lwIP knob maps to the same defines and library as the PIO builder.""" + + config = _resolve(f"-D{knob}") + assert config.lwip_lib == lib + assert f"TCP_MSS={mss}" in config.knob_defines + assert f"LWIP_FEATURES={features}" in config.knob_defines + assert f"LWIP_IPV6={ipv6}" in config.knob_defines + + +def test_lwip_low_memory_loses_to_listed_knobs() -> None: + """The ordinary SNTP multi-server config: sntp emits LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH, and the listed knob must win + exactly as in platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip2-1460" + + +def test_lwip_ipv6_wins_over_default_knob() -> None: + """The shipping IPv6 config: network emits IPV6_LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH; IPv6 must win exactly as in + platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip6-536-feat" + assert "LWIP_IPV6=1" in config.knob_defines + + +def test_unrecognized_compile_flag_shape_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """A link-only spelling missing from the deny lists still reaches the + compile line, but no longer silently.""" + _set_flags("-shared") + compile_flags, _link_flags, _lib_dirs, _libs = _split_flags() + assert "-shared is not a recognized compile-flag shape" in caplog.text + assert "-shared" in compile_flags + + +@pytest.mark.parametrize( + ("knob", "expected"), + [ + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED", + ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED", + [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"], + ), + ], +) +def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None: + + _set_flags(f"-D{knob}") + assert _resolve_build_config(_defines()).mmu_defines == expected + + +def test_build_config_waveform_locked_phase() -> None: + + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM") + assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines + assert config.fp_in_irom + + +_COMMON_LD_H_OUTPUT = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000 +} +SECTIONS +{ + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + } >dram0_0_seg :dram0_0_phdr +} +""" + + +def _run_generate_ld_scripts(paths: InstalledPaths) -> Path: + + config = _resolve_current() + arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld") + return CORE.relative_pioenvs_path(CORE.name, "ld") + + +def test_generate_ld_scripts(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + _set_flags("-DFP_IN_IROM") + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + patch.object(arduino8266._LOGGER, "warning") as mock_warn, + ): + ld_dir = _run_generate_ld_scripts(paths) + # A clean preprocessor run must be quiet + mock_warn.assert_not_called() + content = (ld_dir / "local.eagle.app.v6.common.ld").read_text() + assert RATETABLE_RULE in content + cmd = mock_run.call_args[0][0] + assert "-DVTABLES_IN_FLASH" in cmd + assert "-DMMU_IRAM_SIZE=0x8000" in cmd + assert "-DFP_IN_IROM" in cmd + + # Unchanged inputs skip the preprocessor spawn on the next run + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + + # An edit to the surgery constants invalidates the stamp (a stale linker + # script would otherwise persist until an esphome clean) + with ( + patch.object( + arduino8266.build_surgery, "surgery_fingerprint", return_value="changed" + ), + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + ): + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_corrupt_cache_regenerates(tmp_path: Path) -> None: + """A truncated cached linker script regenerates even with a fresh stamp.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_text("truncated garbage") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + assert RATETABLE_RULE in output.read_text() + + +def test_generate_ld_scripts_failure(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + result = MagicMock(returncode=1, stderr=b"nope") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="linker script failed"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + "MEMORY\n{\n" + " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" + " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" + "}\n" + ) + CORE.testing_mode = True + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text() + assert "len = 0x2000000" in patched + + +def test_write_project_libraries_and_variant( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + + paths = _make_framework(tmp_path) + variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp" + variant_src.write_text("") + + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + (lib_dir / "impl.cc").write_text("") + headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir]) + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "impl.cc", lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + link_dirs=[lib_dir / "blobs"], + link_libs=["algobsec"], + link_flags=["-Wl,--wrap=malloc"], + ) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS") + + with caplog.at_level(logging.DEBUG, logger="esphome.build_helpers.ninja_gen"): + content = _write_ninja( + paths, libraries=[library, headers_only], ccache="/cc/ccache" + ) + + assert "build libFrameworkArduinoVariant.a: ar" in content + assert "build libMyLib.a: ar" in content + # A headers-only library contributes includes but no archive, with a + # debug log distinguishing it from a resolution failure + assert "libHeadersOnly.a" not in content + assert "Library HeadersOnly has no source files" in caplog.text + assert " flags = -DMYLIB=1" in content + # A library's own include dirs lead its compile lines + assert " own_includes = -I" in content + assert "$own_includes $cxxflags $flags" in content + assert "-lalgobsec" in content + # Library link flags reach the firmware link line; .cc compiles as C++ + assert "-Wl,--wrap=malloc" in content + assert "impl.cc.o: cxx" in content + assert f"-L{_shq(str(lib_dir / 'blobs'))}" in content + # Exceptions knob: -fexceptions and the exception-enabled stdc++ + assert "-fexceptions" in content + assert "-lstdc++-exc" in content + assert f"ccache = {_shq('/cc/ccache')}" in content + + +def test_get_flash_ld_path(tmp_path: Path) -> None: + + paths = InstalledPaths( + framework=tmp_path / "framework", + toolchain=tmp_path / "toolchain", + ninja=Path("ninja"), + ) + CORE.testing_mode = True + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "ld" / "testing_eagle.flash.4m.ld" + ) + + CORE.testing_mode = False + # Reads the same install the ninja file linked against; no re-resolve + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld" + ) + + +def test_flash_size_str() -> None: + assert _flash_size_str(4 * 1024 * 1024) == "4M" + assert _flash_size_str(512 * 1024) == "512K" + + +def test_write_project_testing_mode(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.testing_mode = True + _set_flags() + content = _write_ninja(paths) + assert "-T testing_eagle.flash.4m.ld" in content + assert "ld/testing_eagle.flash.4m.ld" in content + + +def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None: + """An incomplete framework install fails naming the missing path.""" + + paths = _make_framework(tmp_path) + shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2") + _set_flags() + with pytest.raises(EsphomeError, match="incomplete.*lwip2"): + _write_ninja(paths) + + +def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( + tmp_path: Path, +) -> None: + """A missing flash ld in testing mode names the file and the fix.""" + paths = _make_framework(tmp_path) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="Could not read .*clean-all"), + ): + _run_generate_ld_scripts(paths) + + +def test_build_config_nonosdk_precedence() -> None: + """With two SDK knobs set (a pathological config), ties break + deterministically by table order.""" + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221", + ) + assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" + + +def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None: + """A lowercase .s source assembles plain (SCons AS), never through the + preprocessor rule that a .S source gets.""" + paths = _make_framework(tmp_path) + core_dir = paths.framework / "cores" / "esp8266" + (core_dir / "lowlevel.s").write_text("nop\n") + _set_flags() + content = _write_ninja(paths) + assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( + content + ) + + +def test_write_project_unflags_operandless_linker_flag(tmp_path: Path) -> None: + """build_unflags: -nostdlib filters whole-token from both lines, as + PlatformIO allows; only operand-taking flags hard-error.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-nostdlib"} + content = _write_ninja(paths) + assert "-nostdlib" not in content + + +def test_write_project_unflagged_symbol_takes_its_dash_u(tmp_path: Path) -> None: + """Unflagging a -u symbol drops the -u that carried it; a dangling -u + would consume the next token and hand ld a symbol as an input file.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"_printf_float"} + content = _write_ninja(paths) + link_line = next( + line for line in content.splitlines() if line.startswith("linkflags = ") + ) + assert "_printf_float" not in link_line + assert "-u -u" not in link_line + # The neighbors survive as intact pairs + assert "-u app_entry" in link_line + assert "-u _DebugExceptionVector" in link_line + + +def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None: + """build_unflags removes flags from the framework sets, as PlatformIO does.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-fipa-pta", "-Wl,--gc-sections"} + content = _write_ninja(paths) + for line in content.splitlines(): + key = line.split(" = ")[0] + if key in ("cflags", "cxxflags", "asflags"): + assert "-fipa-pta" not in line + if key == "linkflags": + assert "-Wl,--gc-sections" not in line + + +def test_project_flags_trailing_bare_linker_flag_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + _set_flags("-l") + compile_flags, link_flags, lib_dirs, libs = _split_flags() + assert "Ignoring trailing '-l'" in caplog.text + assert not libs + assert not lib_dirs + assert "-l" not in compile_flags + assert "-l" not in link_flags + + +def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None: + _set_flags("-L /d -Wl,-Map=m stray") + compile_flags, link_flags, lib_dirs, libs = _split_flags() + assert lib_dirs == [Path("/d")] + assert link_flags == ["-Wl,-Map=m"] + assert "stray" in compile_flags + assert not libs + + +def test_flag_defines_lexes_multi_token_entries() -> None: + """A knob inside a multi-token entry is detected like PlatformIO does.""" + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os") + defines = _defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert defines["FOO"] == "FOO=1" + config = _resolve_build_config(defines) + assert config.lwip_lib == "lwip2-1460" + + +def test_project_flags_lexes_every_entry() -> None: + """A linker flag anywhere in an entry reaches the link line (PIO parity).""" + _set_flags("-DFOO=1 -lbar") + compile_flags, _link, _dirs, libs = _split_flags() + assert libs == ["bar"] + assert "-DFOO=1" in compile_flags + + +def test_project_flags_unflags_match_tokens() -> None: + """build_unflags removes a token embedded in a multi-token entry.""" + _set_flags("-Os -g3") + CORE.build_unflags = {"-Os"} + compile_flags, _link, _dirs, _libs = _split_flags() + assert "-g3" in compile_flags + assert "-Os" not in compile_flags + + +def test_project_flags_requotes_lexed_defines() -> None: + """A quoted spaced value stays one compiler argument after lex/emit.""" + _set_flags('-DGREETING="hello world"') + compile_flags, _link, _dirs, _libs = _split_flags() + # shlex folds the quotes (as PIO's ParseFlags does); _shell_token + # re-quotes the spaced token so the shell passes one argv element + assert compile_flags == [_shq("-DGREETING=hello world")] + + +def test_write_project_empty_core_raises(tmp_path: Path) -> None: + """A framework tree with no core sources fails at generation, not link.""" + paths = _make_framework(tmp_path) + core = paths.framework / "cores" / "esp8266" + for f in core.iterdir(): + f.unlink() + _set_flags() + with pytest.raises(EsphomeError, match="no core sources"): + _write_ninja(paths) + + +def test_flag_defines_joins_spaced_define() -> None: + """A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it.""" + _set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + defines = _defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert "" not in defines + + +def test_ninja_path_escaping() -> None: + """Build-statement paths and command-line paths escape differently.""" + assert arduino8266._e("a b:$c") == "a$ b$:$$c" + assert arduino8266._q("/a b/$x") == _shq("/a b/$$x") + + +def test_write_project_asm_excludes_non_define_user_flags(tmp_path: Path) -> None: + """The ASPPCOM command under PlatformIO never sees CCFLAGS, so only -D/-I user flags + reach assembly compiles.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSER_KNOB=1", "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert "-DUSER_KNOB=1" in asflags + assert "-Wno-volatile" not in asflags + cxxflags = next( + line for line in content.splitlines() if line.startswith("cxxflags") + ) + assert "-Wno-volatile" in cxxflags + + +def test_write_project_returns_changed(tmp_path: Path) -> None: + """The documented contract: True when build.ninja changed, False on an + identical regeneration (pins byte-stable output too).""" + paths = _make_framework(tmp_path) + _set_flags() + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + ): + assert arduino8266.write_project(paths, None) is True + assert arduino8266.write_project(paths, None) is False + + +def test_write_project_missing_elf2bin_raises(tmp_path: Path) -> None: + """A half-extracted package must fail by name at generation, not after + the full compile at the elf2bin edge.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "elf2bin.py").unlink() + _set_flags() + src = CORE.relative_src_path() + (src / "main.cpp").parent.mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + pytest.raises(EsphomeError, match="elf2bin"), + ): + arduino8266.write_project(paths, None) + + +def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None: + """A missing generated source tree is its own error, not an install one.""" + paths = _make_framework(tmp_path) + _set_flags() + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + patch.object( + arduino8266.CORE, "relative_src_path", return_value=tmp_path / "nope" + ), + pytest.raises(EsphomeError, match="source directory"), + ): + arduino8266.write_project(paths, None) + + +def test_build_config_custom_mmu_without_knob_raises() -> None: + """Custom MMU sizes without the CUSTOM knob are refused.""" + with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"): + _resolve("-DMMU_IRAM_SIZE=0xC000") + + +def test_flag_defines_lexes_quoted_single_tokens() -> None: + """A quoted single-token define reads the same as on the compile line.""" + _set_flags('-DMMU_SEC_HEAP="0x40108000"') + assert _defines()["MMU_SEC_HEAP"] == "MMU_SEC_HEAP=0x40108000" + + +def test_flag_defines_duplicate_defines_resolve_deterministically() -> None: + """Duplicate conflicting defines pick the same winner every run (sorted + iteration, last writer wins), independent of the set's hash seed.""" + _set_flags("-DMMU_IRAM_SIZE=0x8000", "-DMMU_IRAM_SIZE=0xC000") + assert _defines()["MMU_IRAM_SIZE"] == "MMU_IRAM_SIZE=0xC000" + + +def test_flag_tables_match_platformio_builder() -> None: + """The transliterated flag lists pinned verbatim, like the define set: + a drift lands as a test failure, not a binary-size regression.""" + assert arduino8266._ASFLAGS == ["-mlongcalls", "-mtext-section-literals"] + assert arduino8266._CFLAGS == [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", + ] + assert arduino8266._CCFLAGS == [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", + ] + # Pins the deliberate -u _scanf_float omission + assert arduino8266._LINKFLAGS == [ + "-Os", + "-nostdlib", + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u", + "app_entry", + "-u", + "_printf_float", + "-u", + "_DebugExceptionVector", + "-u", + "_DoubleExceptionVector", + "-u", + "_KernelExceptionVector", + "-u", + "_NMIExceptionVector", + "-u", + "_UserExceptionVector", + ] + # Order is load-bearing: upstream's LIBS order resolves symbols correctly + assert arduino8266._SYSTEM_LIBS_PRE_LWIP == ["hal", "phy", "pp", "net80211"] + assert arduino8266._SYSTEM_LIBS_POST_LWIP == [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", + ] + + +def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None: + """A half-deleted toolchain cache fails with an ESPHome error naming the + binary, not a FileNotFoundError traceback.""" + paths = _make_framework(tmp_path) + _set_flags() + with pytest.raises(EsphomeError, match="Could not run"): + _run_generate_ld_scripts(paths) + + +def test_write_project_asm_keeps_quoted_defines(tmp_path: Path) -> None: + """A spaced -D/-I user flag arrives shell-quoted; assembly must still + receive it.""" + paths = _make_framework(tmp_path) + _set_flags('-DGREETING="hello world"', "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert _shq("-DGREETING=hello world") in asflags + assert "-Wno-volatile" not in asflags + + +def test_write_project_unarchived_library_links_objects(tmp_path: Path) -> None: + """A libArchive:false library's objects reach the link directly.""" + + paths = _make_framework(tmp_path) + lib_src = tmp_path / "gdb" / "src" + lib_src.mkdir(parents=True) + (lib_src / "GDBStub.cpp").write_text("") + _set_flags() + lib = ArduinoLibrary( + name="GDBStub", + sources=[lib_src / "GDBStub.cpp"], + include_dirs=[lib_src], + lib_archive=False, + ) + content = _write_ninja(paths, libraries=[lib]) + assert "libGDBStub.a" not in content + link_line = next( + line for line in content.splitlines() if line.startswith("build firmware.elf") + ) + assert "GDBStub.cpp.o" in link_line + + +def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None: + """A caller bypassing config validation gets the board named, not a + KeyError.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board" + with pytest.raises(EsphomeError, match="'not_a_board' is not supported"): + _write_ninja(paths) + + +def test_unflag_tokens_join_spaced_entries() -> None: + """Spaced build_unflags entries ("-D FOO") match the joined token.""" + CORE.build_unflags = {"-D FOO", "-l bar"} + tokens = arduino8266._unflag_tokens() + assert tokens == {"-DFOO", "-lbar"} + CORE.build_flags = {"-DFOO -lbar", "-DBAR"} + compile_flags, _link, _dirs, libs = arduino8266._project_flags( + tokens, arduino8266._lexed_build_flags() + ) + assert compile_flags == ["-DBAR"] + assert libs == [] + + +def test_flag_defines_respects_unflags() -> None: + """An unflagged knob must not drive the derived toolchain config.""" + _set_flags("-DVTABLES_IN_DRAM") + defines = _flag_defines({"-DVTABLES_IN_DRAM"}, arduino8266._lexed_build_flags()) + assert "VTABLES_IN_DRAM" not in defines + config = _resolve_build_config(defines) + assert config.vtables == "VTABLES_IN_FLASH" + + +def test_vtables_unknown_raises() -> None: + """An unknown VTABLES_IN_* knob fails by name.""" + with pytest.raises(EsphomeError, match="Unknown VTABLES_IN_.*BANANA"): + _resolve("-DVTABLES_IN_BANANA") + + +def test_vtables_conflicting_raises() -> None: + with pytest.raises(EsphomeError, match="Conflicting VTABLES_IN_"): + _resolve("-DVTABLES_IN_DRAM", "-DVTABLES_IN_IRAM") + + +def test_empty_lib_flags_warned_and_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + """A bare -L would silently add the CWD to the search path; the lex + funnel warns and drops it for every consumer.""" + CORE.build_flags = {'-L ""', '-l ""', "-DFOO"} + assert arduino8266._lexed_build_flags() == ["-DFOO"] + assert "Ignoring '-L' with empty argument" in caplog.text + assert "Ignoring '-l' with empty argument" in caplog.text + + +def test_generate_ld_scripts_surfaces_preprocessor_warnings( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Preprocessor stderr on a zero exit reaches the user; degenerate output is refused.""" + paths = _make_framework(tmp_path) + _set_flags() + result = _ok_result(stderr="warning: something") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "Linker-script preprocessor: warning: something" in caplog.text + + # New flags invalidate the stamp so the degenerate run regenerates + _set_flags("-DVTABLES_IN_DRAM") + result = _ok_result(stdout="") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="SECTIONS"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_lost_warn_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A warn note that could not persist skips the stamp, so the next build + re-runs -E and re-derives the diagnostic instead of losing it.""" + paths = _make_framework(tmp_path) + _set_flags() + result = _ok_result(stderr="warning: something") + real_write_text = Path.write_text + + def fail_note_writes(self: Path, text: str, encoding: str = "utf-8") -> int: + if self.name.endswith(".stderr"): + raise OSError("read-only build dir") + return real_write_text(self, text, encoding=encoding) + + with ( + patch.object(arduino8266.subprocess, "run", return_value=result) as run1, + patch.object(Path, "write_text", fail_note_writes), + ): + _run_generate_ld_scripts(paths) + run1.assert_called_once() + assert "Could not write" in caplog.text + + # Unstamped: the second build re-runs the preprocessor + with patch.object(arduino8266.subprocess, "run", return_value=result) as run2: + _run_generate_ld_scripts(paths) + run2.assert_called_once() + assert caplog.text.count("Linker-script preprocessor: warning: something") == 2 + + +def test_generate_ld_scripts_unremovable_stale_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A stale warn note that cannot be removed skips the stamp, so the + obsolete diagnostic is not re-emitted on cache hits forever.""" + paths = _make_framework(tmp_path) + _set_flags() + warn = _ok_result(stderr="warning: old") + clean = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=warn): + _run_generate_ld_scripts(paths) + + real_unlink = Path.unlink + + def fail_note_unlink(self: Path, missing_ok: bool = False) -> None: + if self.name.endswith(".stderr"): + raise OSError("locked") + real_unlink(self, missing_ok=missing_ok) + + # Flags changed -> regenerate; clean stderr but the stale note is stuck + _set_flags("-DVTABLES_IN_DRAM") + with ( + patch.object(arduino8266.subprocess, "run", return_value=clean), + patch.object(Path, "unlink", fail_note_unlink), + ): + _run_generate_ld_scripts(paths) + # Unstamped: the next build re-runs -E instead of trusting the cache + with patch.object(arduino8266.subprocess, "run", return_value=clean) as run3: + _run_generate_ld_scripts(paths) + run3.assert_called_once() + + +@pytest.mark.parametrize("name", ["MMU_IRAM_SIZE", "MMU_ICACHE_SIZE"]) +def test_mmu_custom_valueless_segment_size_raises(name: str) -> None: + """A bare -Dname would preprocess to len = 1 and fail far away in ld.""" + other = "MMU_ICACHE_SIZE" if name == "MMU_IRAM_SIZE" else "MMU_IRAM_SIZE" + with pytest.raises(EsphomeError, match=f"{name} must be a hex literal"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", f"-D{name}", f"-D{other}=0x8000") + + +@pytest.mark.parametrize( + "flag", ["-fuse-ld=lld", "--specs=nano.specs", "-specs=nano.specs"] +) +def test_driver_link_flags_rejected_without_wl_advice(flag: str) -> None: + """No -Wl, spelling exists for these; the message must not suggest one.""" + CORE.build_flags = {flag, "-DFOO"} + with pytest.raises(EsphomeError, match="not supported by the native toolchain"): + arduino8266._project_flags(set(), arduino8266._lexed_build_flags()) + + +def test_build_config_mmu_knob_with_raw_mmu_flag_raises() -> None: + """A variant knob plus a raw MMU_* define would split the compile line + from the linker script; refuse like the no-knob case.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE conflict with .*CACHE16"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", "-DMMU_IRAM_SIZE=0x4000") + + +def test_build_config_raw_lwip_define_raises() -> None: + """TCP_MSS/LWIP_* belong to the lwIP knobs: a raw value would win the + compile line while the prebuilt library stays the knob's.""" + with pytest.raises(EsphomeError, match="TCP_MSS are set by the .*LWIP2"): + _resolve("-DTCP_MSS=1024") + + +def test_build_config_mmu_defines_do_not_alias_the_table() -> None: + """The resolved list must be a copy; mutating it must not corrupt the + module table for later builds in the same process.""" + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48") + config.mmu_defines.append("MMU_BOGUS") + again = _resolve_current() + assert "MMU_BOGUS" not in again.mmu_defines + assert all(isinstance(v, tuple) for v in arduino8266._MMU_VARIANTS.values()) + + +def test_lexed_build_flags_shared_between_consumers( + caplog: pytest.LogCaptureFixture, +) -> None: + """Lexing once and passing the tokens to both consumers yields the same + result as each lexing itself, with a malformed entry warned once.""" + _set_flags("-DFOO=1 -l", "-Wl,--wrap=x") + tokens = arduino8266._lexed_build_flags() + assert caplog.text.count("Ignoring trailing '-l'") == 1 + assert _flag_defines(set(), tokens) == _flag_defines( + set(), arduino8266._lexed_build_flags() + ) + assert arduino8266._project_flags(set(), tokens) == arduino8266._project_flags( + set(), arduino8266._lexed_build_flags() + ) + + +@pytest.mark.parametrize( + "tok", ["-Tcustom.ld", "-Xlinker", "-u", "-e", "-s", "-static", "-nostartfiles"] +) +def test_project_flags_rejects_plain_linker_forms(tok: str) -> None: + """Plain-form linker flags are refused, naming the -Wl, form.""" + _set_flags(tok) + with pytest.raises(EsphomeError, match="use the -Wl, form"): + arduino8266._project_flags(set(), arduino8266._lexed_build_flags()) + + +def test_project_flags_plain_compile_flags_pass() -> None: + _set_flags("-Os") + compile_flags, _l, _d, _libs = arduino8266._project_flags( + set(), arduino8266._lexed_build_flags() + ) + assert "-Os" in compile_flags + + +def test_generate_ld_scripts_header_change_invalidates_stamp( + tmp_path: Path, +) -> None: + """An in-place framework edit at the same path regenerates the script.""" + paths = _make_framework(tmp_path) + header = paths.framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h" + header.write_text("v1") + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + header.write_text("v2 (longer)") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_unreadable_stamp_regenerates(tmp_path: Path) -> None: + """A non-UTF-8 stamp is a damaged cache: regenerate, never abort.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + (ld_dir / ".local.eagle.app.v6.common.ld.stamp").write_bytes(b"\xff\xfe") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_vtables_valued_define_raises() -> None: + """A VTABLES_IN_* body would split the compile line from the linker + script, which always defines the bare name.""" + with pytest.raises(EsphomeError, match="take no value.*VTABLES_IN_FLASH=0"): + _resolve("-DVTABLES_IN_FLASH=0") + + +def test_defines_flags_invalid_board_raises() -> None: + """The board name lands unquoted in two -D bodies; reject it by name.""" + with pytest.raises(EsphomeError, match="Invalid board name"): + _defines_flags(_resolve(), "dout", "evil board", ()) + + +def test_generate_ld_scripts_invalid_flash_ld_name_raises(tmp_path: Path) -> None: + """The script name joins under the SDK and build ld dirs; a traversal + or path is rejected by name.""" + paths = _make_framework(tmp_path) + _set_flags() + config = _resolve() + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.generate_ld_scripts(paths, config, "../evil.ld") + + +def test_generate_ld_scripts_edited_output_regenerates(tmp_path: Path) -> None: + """The stamp records the content hash, so an externally edited cached + script regenerates instead of linking untrusted content.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_text(output.read_text() + "\n/* tampered */\n") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + assert "tampered" not in output.read_text() + + +def test_generate_ld_scripts_corrupt_output_is_overwritten(tmp_path: Path) -> None: + """A non-UTF-8 cached script must be overwritten by the regeneration, + not abort it (write_file_if_changed reads the old content).""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_bytes(b"\xff\xfe") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "SECTIONS" in output.read_text(encoding="utf-8") + + +def test_generate_ld_scripts_non_utf8_output_raises(tmp_path: Path) -> None: + """A non-UTF-8 byte in the preprocessed script fails by header name; a + U+FFFD-mangled script must never be cached as valid.""" + paths = _make_framework(tmp_path) + result = _ok_result(stdout=b"SECTIONS { }\xff\xfe") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="is not UTF-8"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_note_read_race_still_warns( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A note that turns unreadable between the digest check and the + re-emit read (TOCTOU) warns by path instead of dropping silently.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warn!") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + orig = Path.read_text + + def flaky(self, *args, **kwargs): + if self.name.endswith(".stderr"): + raise OSError("EIO") + return orig(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", flaky) + caplog.clear() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "could not be read" in caplog.text + + +@pytest.mark.parametrize("damage", ["corrupt", "remove"]) +def test_generate_ld_scripts_damaged_note_invalidates_cache( + tmp_path: Path, caplog: pytest.LogCaptureFixture, damage: str +) -> None: + """A corrupted or externally removed diagnostic note is a cache miss: + -E re-runs and re-derives the warning instead of dropping it silently.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warn!") + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + note = ld_dir / ".local.eagle.app.v6.common.ld.stderr" + if damage == "corrupt": + note.write_bytes(b"\xff\xfe") + else: + note.unlink() + caplog.clear() + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + assert mock_run.called + assert "Linker-script preprocessor: warn!" in caplog.text + + +@pytest.mark.parametrize("value", ["0x8000", "0xC000ul", "0x10UL"]) +def test_mmu_custom_numeric_sizes_accepted(value: str) -> None: + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + f"-DMMU_IRAM_SIZE={value}", + "-DMMU_ICACHE_SIZE=0x8000", + ) + assert f"MMU_IRAM_SIZE={value}" in config.mmu_defines + + +@pytest.mark.parametrize( + "flag", + [ + "-DMMU_IRAM_SIZE=48K", + # Decimal passes preprocessing but build_surgery's segment parser + # only reads hex, so testing-mode surgery would fail misleadingly + "-DMMU_IRAM_SIZE=32768", + ], +) +def test_mmu_custom_malformed_size_raises(flag: str) -> None: + """A non-hex size would corrupt the preprocessed segment lengths (or + defeat the testing-mode surgery); refuse by name.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE must be a hex"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + flag, + "-DMMU_ICACHE_SIZE=0x8000", + ) + + +def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None: + """Valueless MMU switches (MMU_IRAM_HEAP) pass; every valued MMU_* is + hex-validated, not just the two required sizes.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_IRAM_HEAP", + ) + assert "MMU_IRAM_HEAP" in config.mmu_defines + with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a numeric"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_SEC_HEAP_SIZE=48K", + ) + + +def test_mmu_custom_accepts_decimal_non_segment_values() -> None: + """MMU_EXTERNAL_HEAP=128 (the module's own EXTERNAL_128K shape) is a + mmu_iram.h count, not a segment length; decimal is legal there while + the two segment sizes stay hex-only for the surgery parser.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_EXTERNAL_HEAP=128", + ) + assert "MMU_EXTERNAL_HEAP=128" in config.mmu_defines + + +def test_mmu_no_knob_rejects_any_raw_mmu_flag() -> None: + """The no-knob branch refuses every raw MMU_*, like the knob branch; a + lone switch would win the compile line but not the linker script.""" + with pytest.raises(EsphomeError, match="Raw MMU_IRAM_HEAP"): + _resolve("-DMMU_IRAM_HEAP") + + +def test_raw_nonosdk_define_raises() -> None: + """A raw NONOSDK* define would split the compile line from the linked + SDK libraries, like the lwIP knob overrides.""" + with pytest.raises(EsphomeError, match="NONOSDK305 are set by the"): + _resolve("-DNONOSDK305=1") + + +def test_write_note_warn_level( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A lost stderr note drops a diagnostic on later cached builds, so it + warns; a lost stamp only costs a cache miss.""" + arduino8266._write_note(tmp_path / "missing" / "note", "x", warn=True) + assert "Could not write" in caplog.text + + +def test_write_note_failure_is_best_effort( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed stamp or stderr-note write costs a cache miss, never the + build.""" + caplog.set_level("DEBUG") + arduino8266._write_note(tmp_path / "missing" / "stamp", "x") + assert "Could not write" in caplog.text + + +def test_pio_option_blank_value_raises() -> None: + """An empty or blank platformio_options value is a config error, not a + silent fallback to the default.""" + CORE.platformio_options = {"board_build.f_cpu": " "} + with pytest.raises(EsphomeError, match="board_build.f_cpu is empty"): + arduino8266._pio_option("board_build.f_cpu", "80000000L") + + +@pytest.mark.parametrize("value", ["160 MHz", "\u0661\u0666\u0660"]) +def test_defines_flags_invalid_f_cpu_raises(value: str) -> None: + """A non-numeric board_build.f_cpu is rejected by name; it would land + unquoted on the compile line. Unicode digits count as non-numeric: the + shape checks are ASCII-only.""" + CORE.platformio_options = {"board_build.f_cpu": value} + with pytest.raises(EsphomeError, match="Invalid board_build.f_cpu"): + _defines_flags( + _resolve(), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) + + +def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None: + """A moved rate-table anchor surfaces as a build error, not a traceback + or a silently unrelocated table.""" + paths = _make_framework(tmp_path) + result = _ok_result(stdout="SECTIONS { no anchor here }") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="anchor not found"), + ): + _run_generate_ld_scripts(paths) + + +def test_write_project_unmatched_unflag_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unflag that removes nothing is named; a matching one is silent.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSE_FOO=1") + CORE.build_unflags = {"-DUSE_FOO", "-Os"} + content = _write_ninja(paths) + assert "matched no build flag: -DUSE_FOO" in caplog.text + assert "-Os" not in caplog.text.split("matched no build flag")[-1].splitlines()[0] + # The matching -Os unflag really removed the framework flag + cflags = next(line for line in content.splitlines() if line.startswith("cflags")) + assert " -Os " not in cflags + + +def test_write_project_lexes_build_flags_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A malformed build_flags entry warns once per generation.""" + paths = _make_framework(tmp_path) + _set_flags("-DFOO=1 -l") + _write_ninja(paths) + assert caplog.text.count("Ignoring trailing '-l'") == 1 + + +def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None: + """With MMU_CUSTOM also set, the actionable fix is dropping the variant + knob, not setting the knob the user already set.""" + with pytest.raises(EsphomeError, match="drop PIO_FRAMEWORK_ARDUINO_MMU_CACHE16"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + + +def test_generate_ld_scripts_testing_surgery_failure_is_named( + tmp_path: Path, +) -> None: + """A testing-mode segment patch failing on a changed linker script is a + named error, like the ratetable surgery.""" + paths = _make_framework(tmp_path) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + patch.object( + arduino8266.build_surgery, + "apply_testing_memory_patches", + side_effect=RuntimeError("iram1_0_seg not found"), + ), + pytest.raises(EsphomeError, match="iram1_0_seg not found"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_flash_ld_surgery_failure_is_named( + tmp_path: Path, +) -> None: + """The flash-ld segment patch gets the same named-error wrap.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + "MEMORY { }" + ) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + patch.object( + arduino8266.build_surgery, + "apply_testing_memory_patches", + side_effect=["patched common", RuntimeError("dram0_0_seg mismatch")], + ), + pytest.raises(EsphomeError, match="dram0_0_seg mismatch"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_reemits_cached_preprocessor_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A preprocessor diagnostic survives cache hits instead of appearing + once and vanishing for the life of the build dir.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warning: something odd") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert caplog.text.count("warning: something odd") == 1 + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + assert caplog.text.count("warning: something odd") == 2 + + +def test_generate_ld_scripts_unreadable_header_forces_regeneration( + tmp_path: Path, +) -> None: + """A stat failure other than absence must miss the cache every run, not + pin the stamp to a constant that can never notice a later edit.""" + paths = _make_framework(tmp_path) + header_name = "eagle.app.v6.common.ld.h" + (paths.framework / "tools" / "sdk" / "ld" / header_name).write_text("v1") + real_stat = Path.stat + + def fake_stat(self: Path, **kwargs: object): + if self.name == header_name: + raise PermissionError(13, "denied") + return real_stat(self, **kwargs) + + result = _ok_result() + with patch.object(Path, "stat", fake_stat): + with patch.object( + arduino8266.subprocess, "run", return_value=result + ) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + with patch.object( + arduino8266.subprocess, "run", return_value=result + ) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_board_tables_are_equal() -> None: + """BOARDS and ESP8266_BOARD_BUILD must stay exactly in sync.""" + assert set(BOARDS) == set(ESP8266_BOARD_BUILD) + + +def test_bare_include_and_define_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + """An empty-argument -I or -D would make gcc eat the next flag as the + argument; the lex funnel warns and drops both.""" + CORE.build_flags = {'-I ""', '-D ""'} + assert arduino8266._lexed_build_flags() == [] + assert "Ignoring '-I' with empty argument" in caplog.text + assert "Ignoring '-D' with empty argument" in caplog.text + + +def test_generate_ld_scripts_gcc_change_invalidates_stamp(tmp_path: Path) -> None: + """An in-place toolchain re-extraction regenerates the script, same as + the header stat.""" + paths = _make_framework(tmp_path) + gcc = toolchain_tool(paths.toolchain, "gcc") + gcc.write_text("v1") + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + gcc.write_text("v2 (longer)") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_defines_flags_honors_f_cpu_override() -> None: + """board_build.f_cpu (a published-config overclock knob) reaches the + compile line; the default stays 80 MHz.""" + _set_flags() + config = _resolve_build_config(_flag_defines(set(), [])) + board_build = ESP8266_BOARD_BUILD["nodemcuv2"] + defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) + assert "-DF_CPU=80000000L" in defines + CORE.platformio_options = {"board_build.f_cpu": "160000000L"} + defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) + assert "-DF_CPU=160000000L" in defines + + +def test_flash_ld_name_honors_ldscript_override(tmp_path: Path) -> None: + """board_build.ldscript (filesystem reservation, corrected flash size) + replaces the board default; a path is rejected since the name resolves + via the -L search path.""" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + CORE.platformio_options = {"board_build.ldscript": "eagle.flash.4m2m.ld"} + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m2m.ld" + paths = _make_framework(tmp_path) + _set_flags() + content = _write_ninja(paths) + assert "-T eagle.flash.4m2m.ld" in content + CORE.platformio_options = {"board_build.ldscript": "../evil.ld"} + with pytest.raises(EsphomeError, match="bare script name"): + arduino8266._flash_ld_name("nodemcuv2") + + +def test_unflagging_a_plain_linker_flag_raises(tmp_path: Path) -> None: + """build_unflags: -u would strip all seven -u tokens and leave the + operands as ld input files; refuse by name instead.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-u _printf_float"} + with pytest.raises(EsphomeError, match="cannot remove plain linker"): + _write_ninja(paths) + + +def test_d1_wroom_02_keeps_its_shipped_flash_layout() -> None: + """The board joined BOARDS late; the flash-size default (2m.ld) would + move _FS_end and the preferences sector on existing devices.""" + assert arduino8266._flash_ld_name("d1_wroom_02") == "eagle.flash.2m64.ld" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + + +def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None: + """A spaced override never reaches the link line: generate_ld_scripts + rejects the name first (the -T _shell_token quoting behind it is + defence-in-depth).""" + CORE.platformio_options = {"board_build.ldscript": "my script.ld"} + paths = _make_framework(tmp_path) + _set_flags() + # The completeness checks run first; give them a src dir to pass + CORE.relative_src_path().mkdir(parents=True, exist_ok=True) + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.write_project(paths, None) + + +def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None: + """Regression: the -include stays relative and the .sum carries no + per-device path, or cross-device ccache sharing breaks.""" + paths = _make_framework(tmp_path / "shared") + sums = [] + for name in ("dev_a", "dev_b"): + CORE.name = name + CORE.build_path = tmp_path / name + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert ( + "srccxxflags = -Winvalid-pch -Werror=invalid-pch " + "-include esphome_pch.h" in content + ) + sums.append( + (CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text() + ) + assert sums[0] == sums[1] + + +def test_write_project_pch_sum_tracks_src_flags(tmp_path: Path) -> None: + """The header compiles with the src flags too, so they are part of what + ccache is told about it.""" + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + sum_path = CORE.relative_pioenvs_path(CORE.name) / "esphome_pch.h.gch.sum" + sums = [] + for value in ("1", "2"): + CORE.platformio_options["build_src_flags"] = f"-DSRC_ONLY={value}" + _write_ninja(paths) + sums.append(sum_path.read_text()) + assert sums[0] != sums[1] diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 079f10ddb91..4a842847962 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -163,6 +163,18 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None: assert has_discovered_components() +def test_get_project_cmakelists_size_command_uses_json2() -> None: + """The POST_BUILD size command uses the cheap json2 format, with --ng + only on the 1.x tool bundled with IDF < 6.""" + content = _render() + assert "-m esp_idf_size --ng --format=json2" in content + + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 0, 0) + content = _render() + assert "--ng" not in content + assert "--format=json2" in content + + def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: """A cached list replaces project_description.json and is still filtered by EXCLUDE_COMPONENTS.""" @@ -488,3 +500,126 @@ def test_get_component_cmakelists_no_compile_features() -> None: content = get_component_cmakelists() assert "target_compile_features" not in content + + +def _make_pch_project(tmp_path: Path) -> Path: + """A build path with the core headers, an sdkconfig and a lock file.""" + from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS + + CORE.build_path = tmp_path + for header in PCH_DEFAULT_HEADERS: + path = tmp_path / "src" / header + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('#include "esphome/core/defines.h"\n') + (tmp_path / "src" / "esphome" / "core" / "defines.h").write_text("#define M 1\n") + (tmp_path / "sdkconfig.test").write_text("CONFIG_X=y\n") + (tmp_path / "dependencies.lock").write_text("espressif/mdns: 1.12.0\n") + return tmp_path + + +def _pch_checksum() -> str: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + with patch.object(CORE, "name", "test"): + write_pch_checksum() + return CORE.relative_build_path(_PCH_SUM_PATH).read_text() + + +def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None: + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_precompile_headers(${COMPONENT_LIB} PRIVATE\n" + ' "$<$:${CMAKE_CURRENT_SOURCE_DIR}/' + 'esphome/core/pch_prefix.h>"\n)' + ) in content + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert "target_precompile_headers" not in get_component_cmakelists() + + +def test_component_cmakelists_pch_gate_on_windows( + windows_gcc_rule: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """The block carries the rule and records its choice; the knob drops + the gate.""" + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + 'if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ' + "(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR " + "(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND " + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)))\n" + " message(STATUS " in content + ) + assert ( + ' set(ESPHOME_PCH OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)\nelse()\n' + in content + ) + assert ( + ' set(ESPHOME_PCH ON CACHE BOOL "ESPHome precompiled header in use" FORCE)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n' + in content + ) + assert "endif()" in content + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + content = get_component_cmakelists() + assert "if(CMAKE_CXX_COMPILER_VERSION" not in content + assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content + + +@pytest.mark.parametrize(("choice", "written"), [("OFF", False), ("ON", True)]) +def test_pch_checksum_follows_the_cmake_choice_on_windows( + windows_gcc_rule: None, tmp_path: Path, choice: str, written: bool +) -> None: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + _make_pch_project(tmp_path) + with ( + patch( + "esphome.espidf.toolchain.get_cmake_cache_value", return_value=choice + ) as asked, + patch.object(CORE, "name", "test"), + ): + write_pch_checksum() + assert asked.call_args.args == ("ESPHOME_PCH",) + assert CORE.relative_build_path(_PCH_SUM_PATH).exists() is written + + +@pytest.mark.parametrize( + ("file", "content"), + [ + ("src/esphome/core/defines.h", "#define M 2\n"), + ("sdkconfig.test", "CONFIG_X=n\n"), + ("dependencies.lock", "espressif/mdns: 1.13.0\n"), + ], +) +def test_pch_checksum_tracks_its_inputs( + tmp_path: Path, file: str, content: str +) -> None: + """The checksum stands in for the .gch in ccache, so it has to change + with a core header, the sdkconfig and a managed component version.""" + project = _make_pch_project(tmp_path) + first = _pch_checksum() + assert len(first.strip()) == 64 + (project / file).write_text(content) + assert _pch_checksum() != first + + +def test_pch_checksum_is_the_same_for_two_devices(tmp_path: Path) -> None: + sums = [] + for name in ("dev_a", "dev_b"): + _make_pch_project(tmp_path / name) + sums.append(_pch_checksum()) + assert sums[0] == sums[1] + + +def test_pch_checksum_disabled_writes_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + _make_pch_project(tmp_path) + write_pch_checksum() + assert not CORE.relative_build_path(_PCH_SUM_PATH).exists() diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py new file mode 100644 index 00000000000..9e40698c91c --- /dev/null +++ b/tests/unit_tests/build_gen/test_host.py @@ -0,0 +1,441 @@ +"""Tests for esphome.build_gen.host (the host ninja generator).""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from esphome.arduino.library import ArduinoLibrary +from esphome.build_gen import host as build_gen +from esphome.build_helpers.ninja import escape as _e, quote_path as _q +from esphome.build_helpers.ninja_gen import Flag +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST +from esphome.core import CORE, EsphomeError, Library +from esphome.host.toolchain import PROGRAM_NAME, HostCompilers + +COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",)) + + +@pytest.fixture(autouse=True) +def _core(tmp_path: Path) -> None: + CORE.build_path = tmp_path + CORE.name = "dev" + CORE.cpp_standard = "gnu++20" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} + + +def _make_src(tmp_path: Path, *names: str) -> Path: + src = tmp_path / "src" + for name in names: + path = src / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return src + + +def _render( + ccache: str | None = None, + version: str = "g++ 1.0", + compilers: HostCompilers = COMPILERS, +) -> tuple[bool, str]: + with patch.object(build_gen, "_compiler_version", return_value=version): + changed = build_gen.write_project(compilers, ccache) + ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja" + return changed, ninja.read_text() + + +@pytest.mark.parametrize( + ("entries", "compile_flags", "link_flags"), + [ + ( + ["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"], + [("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)], + [], + ), + (["-std=gnu++20"], [("-std=gnu++20",)], []), + ( + ["-lssl", "-L/opt/lib", "-Wl,--gc-sections"], + [], + [("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)], + ), + # A link-only flag travels with its argument + ( + ["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"], + [], + [ + ("-Xlinker", "--wrap=malloc"), + ("-z", "noexecstack"), + ("-framework", "Cocoa"), + ], + ), + # Both lines, as SCons routes unclassified flags + ( + ["-g -O2", "-fsanitize=address", "-pthread", "--coverage"], + [ + ("-g",), + ("-O2",), + ("-fsanitize=address",), + ("-pthread",), + ("--coverage",), + ], + [ + ("-g",), + ("-O2",), + ("-fsanitize=address",), + ("-pthread",), + ("--coverage",), + ], + ), + # A flag with a path argument travels with it, glued on or not + ( + ["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"], + [("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)], + [], + ), + ], +) +def test_split_flags( + entries: list[str], compile_flags: list[Flag], link_flags: list[Flag] +) -> None: + flags = build_gen.parse_flags(entries, "esphome") + assert build_gen.split_flags(flags) == (compile_flags, link_flags) + + +@pytest.mark.parametrize( + "entries", + [ + # The flag is the last token of its entry + ["-g -include"], + # Entries are a set: the next entry is not this flag's argument + ["-include", "pre.h"], + ["-framework", "-pthread"], + # A path never starts with "-" + ["-include -pthread"], + ], +) +def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None: + with pytest.raises(EsphomeError, match="with no argument; write the flag"): + build_gen.parse_flags(entries, "esphome") + + +def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None: + assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [ + ("-Xlinker", "--wrap=malloc") + ] + + +def test_flag_lists_route_the_standard() -> None: + """cpp_standard wins for C++; C never sees a C++ standard.""" + CORE.build_flags = { + "-DUSE_HOST", + "-Iinc", + "-Wl,-x", + "-g", + "-lssl", + "-std=c++17", + "-std=gnu17", + } + CORE.cxx_build_flags = {"-Wno-volatile"} + cflags, cxxflags, link_flags = build_gen._flag_lists() + # The relative include is anchored at the build path + inc = f"-I{CORE.build_path / 'inc'}" + assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"] + assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"] + assert link_flags == ["-Wl,-x", "-g", "-lssl"] + + +def test_flag_lists_without_cpp_standard_keeps_user_std() -> None: + CORE.cpp_standard = None + CORE.build_flags = {"-std=c++17"} + cflags, cxxflags, _link = build_gen._flag_lists() + assert cflags == [] + assert cxxflags == ["-std=c++17"] + + +def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None: + CORE.build_flags = {"-g", "-DUSE_HOST"} + CORE.build_unflags = {"-g", "-Onope"} + with caplog.at_level(logging.WARNING): + cflags, cxxflags, link_flags = build_gen._flag_lists() + assert "-g" not in cflags + cxxflags + link_flags + assert "-DUSE_HOST" in cflags + assert "build_unflags entries matched no build flag: -Onope" in caplog.text + + +def test_resolve_host_libraries_without_libraries() -> None: + with patch("esphome.arduino.library.resolve_libraries") as resolve: + assert build_gen._resolve_host_libraries() == [] + resolve.assert_not_called() + + +def test_resolve_host_libraries_is_framework_less() -> None: + CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0")) + lib = ArduinoLibrary(name="lvgl") + with patch( + "esphome.arduino.library.resolve_libraries", return_value=[lib] + ) as resolve: + assert build_gen._resolve_host_libraries() == [lib] + resolve.assert_called_once_with( + None, + pio_platform="native", + board_mcu="host", + cache_key="host", + framework=None, + manifest_optional=True, + ) + + +def test_write_project_requires_generated_sources(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Generated source directory .* is missing"): + build_gen.write_project(COMPILERS, None) + _make_src(tmp_path, "esphome.h") + with pytest.raises(EsphomeError, match="No source files found"): + _render() + + +def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: + src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h") + CORE.build_flags = {"-DUSE_HOST", "-g"} + changed, ninja = _render(ccache="/usr/bin/ccache") + assert changed is True + # A compiler override's arguments follow the program + assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja + assert f"cxx = {_q('/usr/bin/g++')}" in ninja + assert f"ccache = {_q('/usr/bin/ccache')}" in ninja + assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja + assert ( + f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja + ) + assert "build obj/src/x.S.o: aspp " in ninja + assert "build obj/src/y.s.o: asm " in ninja + assert _e(src / "h.h") not in ninja + # __FILE__ stays relative to the build path, as it was under PlatformIO, + # whether the source arrives absolute or rewritten by ccache + sep = os.sep + maps = ( + f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} " + f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}" + ) + assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja + assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja + # Assembly gets the defines and includes only + assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja + assert "linkflags = -g\n" in ninja + assert "libdirflags = \n" in ninja + assert "libflags = \n" in ninja + assert "rule ar" not in ninja + assert ( + f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o " + f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n" + ) in ninja + # Unchanged content reports no change so the compile DB can be reused + changed, _ = _render(ccache="/usr/bin/ccache") + assert changed is False + + +def test_write_project_precompiles_the_core_headers(tmp_path: Path) -> None: + """C++ src edges load one precompiled prefix; C and assembly do not.""" + src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S") + _, ninja = _render() + build_dir = tmp_path / ".pioenvs" / "dev" + source = build_dir / "esphome_pch_src.h" + assert source.read_text() == '#include "esphome/core/pch_prefix.h"\n' + assert "rule pch\n" in ninja + sum_path = build_dir / "esphome_pch.h.gch.sum" + assert ( + f"build esphome_pch.h.gch: pch {_e(source)} | esphome_pch.h.gch.sum\n" in ninja + ) + assert "srccxxflags = -Winvalid-pch " in ninja + assert ( + f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')} | esphome_pch.h.gch\n" + " flags = $srccxxflags\n" + ) in ninja + assert ( + f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}\n" + ) in ninja + assert f"build obj/src/x.S.o: aspp {_e(src / 'x.S')}\n" in ninja + # An updated compiler behind the same path rebuilds the header + first = sum_path.read_text() + _render(version="g++ 2.0") + assert sum_path.read_text() != first + + +def test_compiler_version_asks_the_compiler() -> None: + assert build_gen._compiler_version((sys.executable,)).startswith("Python ") + + +def test_write_project_pch_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + src = _make_src(tmp_path, "main.cpp") + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + # The compiler is not asked for its version either + with patch.object(build_gen, "_compiler_version", side_effect=AssertionError): + build_gen.write_project(COMPILERS, None) + ninja = (tmp_path / ".pioenvs" / "dev" / "build.ninja").read_text() + assert "esphome_pch" not in ninja + assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}\n" in ninja + + +def test_write_project_pch_skipped_for_a_user_force_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A -include in the build flags precedes the prefix, so the compiler + would never load the precompiled header.""" + _make_src(tmp_path, "main.cpp") + CORE.build_flags = {"-include pre.h"} + _, ninja = _render() + assert "esphome_pch" not in ninja + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """CXX="g++ -include pre.h" puts it ahead of every flag.""" + _make_src(tmp_path, "main.cpp") + override = HostCompilers(cc=COMPILERS.cc, cxx=("/usr/bin/g++", "-include", "pre.h")) + _, ninja = _render(compilers=override) + assert "esphome_pch" not in ninja + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_without_ccache(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + _changed, ninja = _render() + assert "ccache = \n" in ninja + + +def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + lib_dir = tmp_path / "opt" / "lib" + # Forward slashes: build flags are lexed like a POSIX shell line, which + # reads a backslash as an escape + CORE.build_flags = { + f"-L{lib_dir.as_posix()}", + "-lcrypto", + "-Wl,-framework,Security", + } + _changed, ninja = _render() + assert "linkflags = -Wl,-framework,Security\n" in ninja + assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja + assert "libflags = -lcrypto\n" in ninja + + +def _libraries(tmp_path: Path) -> list[ArduinoLibrary]: + lib_dir = tmp_path / "libs" + for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"): + path = lib_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + archived = ArduinoLibrary( + name="foo", + sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"], + include_dirs=[lib_dir / "foo/src"], + flags=["-DFOO=1"], + link_dirs=[lib_dir / "foo/lib"], + link_libs=["bar"], + link_flags=["-Wl,--gc-sections"], + ) + direct = ArduinoLibrary( + name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False + ) + header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"]) + return [archived, direct, header_only] + + +def test_write_project_with_libraries( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + src = _make_src(tmp_path, "main.cpp") + libs = _libraries(tmp_path) + with ( + patch.object(build_gen, "_resolve_host_libraries", return_value=libs), + patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool, + patch.object(build_gen.sys, "platform", "linux"), + caplog.at_level(logging.DEBUG), + ): + _changed, ninja = _render() + find_tool.assert_called_once_with("AR", ("ar",)) + assert ( + f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp" + in ninja + ) + lib_dir = tmp_path / "libs" + # Every library's include dir joins the global include path + assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja + assert "linkflags = -Wl,--gc-sections\n" in ninja + assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja + assert "libflags = -lbar\n" in ninja + # Library sources compile with the library's own flags, rooted at their + # common parent; its own include dirs lead the line so another library's + # header of the same name cannot shadow them + own = f"-I{_q(lib_dir / 'foo/src')}" + assert "$own_includes $cxxflags $flags" in ninja + assert ( + f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n" + f" own_includes = {own}\n flags = -DFOO=1\n" in ninja + ) + assert ( + f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n" + f" own_includes = {own}\n flags = -DFOO=1\n" in ninja + ) + assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja + # libArchive: false objects link directly; the archive is an order-only + # input wrapped in a group for GNU ld + assert ( + f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o " + "| libfoo.a\n" + " archives = -Wl,--start-group libfoo.a -Wl,--end-group\n" + ) in ninja + assert "Library hdr has no source files" in caplog.text + + +def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + libs = _libraries(tmp_path) + with ( + patch.object(build_gen, "_resolve_host_libraries", return_value=libs), + patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"), + patch.object(build_gen.sys, "platform", "darwin"), + ): + _changed, ninja = _render() + assert " archives = libfoo.a\n" in ninja + assert "--start-group" not in ninja + + +def test_build_unflags_remove_a_flag_with_its_argument( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the named pair goes; the same path under another flag stays.""" + CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"} + CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"} + with caplog.at_level(logging.WARNING): + cflags, _cxxflags, link_flags = build_gen._flag_lists() + assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")] + assert link_flags == ["-g"] + assert "matched no build flag" not in caplog.text + + +def test_build_unflags_warn_when_nothing_matches( + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.build_flags = {"-g"} + CORE.build_unflags = {"-isystem inc", "-DNOPE"} + with caplog.at_level(logging.WARNING): + build_gen._flag_lists() + assert "matched no build flag: -DNOPE, -isystem " in caplog.text + + +def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None: + CORE.build_flags = {"-Iinc", "-g"} + CORE.build_unflags = {"-Iinc"} + with caplog.at_level(logging.WARNING): + cflags, _cxxflags, _link = build_gen._flag_lists() + assert cflags == ["-g"] + assert "matched no build flag" not in caplog.text diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 20acbe302c1..935265a0042 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest from esphome.build_gen import platformio +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM from esphome.core import CORE @@ -170,6 +171,40 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(CORE, "build_flags", set()) monkeypatch.setattr(CORE, "build_unflags", set()) monkeypatch.setattr(CORE, "cmake_args", {}) + # A platform that does not take the pch script + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: "nrf52"}) + + +def test_pch_script_is_registered_and_copied( + clean_core: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """One place registers the pch script for every PlatformIO platform + that takes it.""" + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: "esp8266"}) + monkeypatch.setattr(CORE, "build_path", tmp_path) + + platformio.write_project() + + assert "post:pch.py" in (tmp_path / "platformio.ini").read_text() + assert (tmp_path / "pch.py").is_file() + + +@pytest.mark.parametrize("disabled_by", ["platform", "knob"]) +def test_pch_script_is_left_out( + clean_core: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + disabled_by: str, +) -> None: + if disabled_by == "knob": + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: "esp8266"}) + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + monkeypatch.setattr(CORE, "build_path", tmp_path) + + platformio.write_project() + + assert "pch.py" not in (tmp_path / "platformio.ini").read_text() + assert not (tmp_path / "pch.py").exists() def test_get_ini_content_pins_cpp_standard( diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py index 0237db40811..17d79b1ddb6 100644 --- a/tests/unit_tests/build_helpers/test_ccache.py +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from esphome.build_helpers import ccache +from esphome.core import CORE def test_resolve_opt_out() -> None: @@ -120,3 +121,77 @@ def test_parse_enable_env_spelling_tables( """cv.boolean's spelling tables plus the 1/0 env convention.""" monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw) assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected + + +def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None: + CORE.build_path = tmp_path + # Drive-qualified on Windows: "/custom/base" is not absolute there + base = "C:\\custom\\base" if os.name == "nt" else "/custom/base" + with patch.dict(os.environ, {"CCACHE_BASEDIR": base}, clear=True): + assert ccache.effective_ccache_basedir() == base + with patch.dict(os.environ, {}, clear=True): + assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) + # Degenerate values would strip substrings ccache never rewrites + for bad in ("", "/", "a/b"): + with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True): + assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) + + +def test_resolve_absolute_ccache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"): + resolved = ccache.resolve_absolute_ccache_path() + assert Path(resolved) == tmp_path / "bin" / "ccache" + with patch.object(ccache, "resolve_ccache_path", return_value=None): + assert ccache.resolve_absolute_ccache_path() is None + + +def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from esphome.core import CORE + + CORE.build_path = tmp_path / "build" + spec = ("ESPHOME_TEST_PREFIX", "test") + monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache")) + monkeypatch.setenv("CCACHE_NOHASHDIR", "false") + for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"): + monkeypatch.delenv(key, raising=False) + # None means resolved and disabled + assert ccache.ccache_env(None, spec) == {} + env = ccache.ccache_env("/usr/bin/ccache", spec) + # User-set values are respected; the rest get defaults + assert "CCACHE_NOHASHDIR" not in env + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_defaults_env_escapes_a_dollar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A literal $ is doubled, since ccache expands $VAR in its settings.""" + from esphome.core import CORE + + CORE.build_path = tmp_path / "a$b" + for key in ("CCACHE_DIR", "CCACHE_BASEDIR"): + monkeypatch.delenv(key, raising=False) + env = ccache.ccache_defaults_env(tmp_path / "c$d") + assert env["CCACHE_DIR"].endswith("c$$d") + assert env["CCACHE_BASEDIR"].endswith("a$$b") + + +def _pch_ccache_env(tmp_path: Path, environ: dict[str, str]) -> dict[str, str]: + CORE.build_path = tmp_path / "build" + spec = ("ESPHOME_TEST_PREFIX", "test") + environ = {"ESPHOME_TEST_PREFIX": str(tmp_path / "cache"), **environ} + with patch.dict(os.environ, environ, clear=True): + return ccache.ccache_env("/usr/bin/ccache", spec) + + +def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None: + """A native build exports the ccache settings the pch needs.""" + env = _pch_ccache_env(tmp_path, {}) + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index fcf9c67086c..9279ca52b2c 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -676,3 +676,36 @@ def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None: ) mock_build.assert_not_called() assert data["cached"] is True + + +def test_warn_if_idedata_missing_is_silent_on_success( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + idedata.warn_if_idedata_missing(lambda: {"cc_path": "gcc"}) + assert "idedata" not in caplog.text + + +def test_warn_if_idedata_missing_warns_on_none( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + idedata.warn_if_idedata_missing(lambda: None) + assert "No idedata was generated for this build" in caplog.text + + +@pytest.mark.parametrize( + "error", [EsphomeError("db unusable"), ValueError("bad"), OSError("gone")] +) +def test_warn_if_idedata_missing_downgrades_failures( + caplog: pytest.LogCaptureFixture, error: Exception +) -> None: + """The firmware already built; a broken idedata must not fail the build.""" + + def broken() -> dict | None: + raise error + + with caplog.at_level(logging.DEBUG): + idedata.warn_if_idedata_missing(broken) + assert f"Could not generate idedata: {error}" in caplog.text + assert "Idedata failure detail" in caplog.text diff --git a/tests/unit_tests/build_helpers/test_native.py b/tests/unit_tests/build_helpers/test_native.py new file mode 100644 index 00000000000..57be9db26ac --- /dev/null +++ b/tests/unit_tests/build_helpers/test_native.py @@ -0,0 +1,34 @@ +"""Tests for esphome.build_helpers.native.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.build_helpers import native +from esphome.const import Toolchain +from esphome.core import CORE + + +def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: + """Options a native build drops are warned by name; consumed ones stay quiet.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.platformio_options = { + "lib_ignore": ["x"], + "board_build.filesystem": "littlefs", + } + with caplog.at_level(logging.WARNING): + native.warn_ignored_platformio_options({"lib_ignore"}) + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "lib_ignore" not in caplog.text + + +def test_warn_ignored_platformio_options_without_options( + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.platformio_options = None + with caplog.at_level(logging.WARNING): + native.warn_ignored_platformio_options(()) + assert caplog.text == "" diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py index 6f0bbda0b99..42782a1f058 100644 --- a/tests/unit_tests/build_helpers/test_ninja.py +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -141,3 +141,126 @@ def test_shell_token_windows_branch_uses_argv_rule() -> None: with patch.object(os, "name", "nt"): assert ninja_helper.shell_token("a b") == '"a b"' assert ninja_helper.shell_token("", force=True) == '""' + + +@pytest.mark.parametrize("char", ["|", "\n", "\r"]) +def test_escape_rejects_what_ninja_cannot_express(char: str) -> None: + with pytest.raises(EsphomeError, match="a ninja build file cannot express"): + ninja_helper.escape(f"src/a{char}b.cpp") + + +def test_escape_keeps_a_hash() -> None: + """A # is only a comment at the start of a ninja line.""" + assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp" + + +def test_absolute_tool_anchors_a_relative_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja" + + +def test_find_ninja_returns_an_absolute_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + with ( + patch("shutil.which", return_value="bin/ninja"), + patch.object(ninja_helper, "_ninja_runs", return_value=True), + ): + assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja" + + +@pytest.fixture +def compdb_dir(tmp_path: Path) -> Path: + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "build.ninja").write_text("rule x\n") + return build_dir + + +def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None: + ninja = Path("ninja") + compdb = compdb_dir / "compile_commands.json" + stamp = compdb_dir / ".compile_commands.stamp" + with patch.object(ninja_helper, "write_compile_commands") as write: + # A rewritten manifest always regenerates + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True) + assert write.call_count == 1 + assert stamp.is_file() + # No compile DB yet + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 2 + compdb.write_text("[]") + # Fresh stamp: nothing to do + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 2 + # A manifest newer than the stamp (interrupted previous run) + os.utime(stamp, (1, 1)) + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 3 + # A missing stamp regenerates too + stamp.unlink() + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 4 + + +def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock: + return MagicMock(returncode=rc, stdout=stdout, stderr=stderr) + + +def test_write_compile_commands_success(compdb_dir: Path) -> None: + entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' + with patch("subprocess.run", return_value=_completed(stdout=entries)) as run: + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"}) + assert (compdb_dir / "compile_commands.json").read_text() == entries + # Every compile rule, so preprocessed assembly reaches the database too + assert run.call_args.args[0] == [ + "ninja", + "-C", + str(compdb_dir), + "-t", + "compdb", + "c", + "cxx", + "aspp", + "asm", + ] + assert run.call_args.kwargs["env"] == {"A": "1"} + + +@pytest.mark.parametrize( + ("result", "message"), + [ + (_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"), + # A parse failure names its cause, not the rule-name story + (_completed(stdout="not json"), "unparsable compile database.*not json"), + (_completed(stdout="[]"), "empty compile database"), + ], +) +def test_write_compile_commands_failures_drop_stale_db( + compdb_dir: Path, result: MagicMock, message: str +) -> None: + compdb = compdb_dir / "compile_commands.json" + compdb.write_text("[stale]") + with ( + patch("subprocess.run", return_value=result), + pytest.raises(EsphomeError, match=message), + ): + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) + assert not compdb.exists() + + +def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db( + compdb_dir: Path, +) -> None: + """The idedata cache is keyed on the DB's mtime.""" + entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' + compdb = compdb_dir / "compile_commands.json" + compdb.write_text(entries) + os.utime(compdb, (1000, 1000)) + with patch("subprocess.run", return_value=_completed(stdout=entries)): + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) + assert compdb.stat().st_mtime == 1000 diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py new file mode 100644 index 00000000000..e7f9b96af2e --- /dev/null +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -0,0 +1,57 @@ +"""Tests for esphome.build_helpers.ninja_gen.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from esphome.build_helpers import ninja_gen +from esphome.build_helpers.ninja_gen import Flag + + +@pytest.mark.parametrize( + ("flag", "anchored"), + [ + (("-Iinc",), ("-I{base}/inc",)), + (("-Llib",), ("-L{base}/lib",)), + (("-include", "pre.h"), ("-include", "{base}/pre.h")), + (("-isystem", "sys"), ("-isystem", "{base}/sys")), + # The glued spelling of a flag that takes a path + (("-isystemsys",), ("-isystem{base}/sys",)), + (("-includepre.h",), ("-include{base}/pre.h",)), + # An absolute operand is never changed + (("-I{base}/abs",), ("-I{base}/abs",)), + (("-include", "{base}/abs.h"), ("-include", "{base}/abs.h")), + # Not a path + (("-DUSE_HOST",), ("-DUSE_HOST",)), + (("-lssl",), ("-lssl",)), + (("-framework", "Cocoa"), ("-framework", "Cocoa")), + (("-I",), ("-I",)), + ], +) +def test_anchor_path_flag(tmp_path: Path, flag: Flag, anchored: Flag) -> None: + """Relative operands resolve from the build path, as under PlatformIO.""" + + def fill(tokens: Flag) -> Flag: + return tuple( + str(Path(tok.replace("{base}", str(tmp_path)))) + if "{base}" in tok and not tok.startswith("-") + else tok.replace("{base}/", f"{tmp_path}{os.sep}") + for tok in tokens + ) + + assert ninja_gen.anchor_path_flag(fill(flag), tmp_path) == fill(anchored) + + +def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: + for name in ("b.cpp", "a.c", "skip.cpp", "notes.txt", "sub/c.S"): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + assert ninja_gen.collect_sources(tmp_path, {"skip.cpp"}) == [ + tmp_path / "a.c", + tmp_path / "b.cpp", + tmp_path / "sub" / "c.S", + ] diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py new file mode 100644 index 00000000000..1182dc6f8cd --- /dev/null +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -0,0 +1,201 @@ +"""Tests for esphome.build_helpers.pch.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import pch +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE + + +def _write(src_dir: Path, name: str, content: str) -> None: + path = src_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, True), + ("1", True), + ("0", False), + ("false", False), + ("", False), + ], +) +def test_pch_enabled(value: str | None, expected: bool) -> None: + env = {} if value is None else {"ESPHOME_PCH_ENABLE": value} + with patch.dict(os.environ, env, clear=True): + assert pch.pch_enabled() is expected + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ((), False), + ((10, 3), False), + ((12, 2, 1), False), + ((14, 2, 0), False), + ((14, 3), False), + ((14, 4), True), + ((14,), False), + ((15, 2, 0), False), + ((15, 3), True), + ((16, 0), True), + ((17, 1), True), + ], +) +def test_gcc_relocates_pch_on_windows(version: tuple[int, ...], expected: bool) -> None: + assert pch.gcc_relocates_pch_on_windows(version) is expected + + +@pytest.mark.parametrize( + ("banner", "expected"), + [ + ("xtensa-esp32-elf-g++ (crosstool-NG esp-14.2.0_20260121) 14.2.0", (14, 2, 0)), + ("arm-zephyr-eabi-g++ (Zephyr SDK 0.16.8) 12.2.0", (12, 2, 0)), + ("g++.exe (Rev3, Built by MSYS2 project) 14.2.0", (14, 2, 0)), + ("clang version 15.0.0", None), + ("Apple clang version 17.0.0 (clang-1700.0.13.3)", None), + ("something else", ()), + ], +) +def test_gcc_version_reads_the_banner(banner: str, expected: object) -> None: + cxx = (sys.executable, "-c", f"print({banner!r}); print('more')") + assert pch.gcc_version(cxx) == expected + + +def test_gcc_version_of_a_compiler_that_cannot_run() -> None: + assert pch.gcc_version(("/nonexistent/g++",)) == () + + +def test_pch_usable_asks_the_compiler_on_windows_only( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger=pch.__name__) + monkeypatch.delenv("ESPHOME_PCH_ENABLE") + monkeypatch.setattr(pch.sys, "platform", "darwin") + with patch.object(pch, "gcc_version", side_effect=AssertionError("off Windows")): + assert pch.pch_usable(("g++",)) + monkeypatch.setattr(pch.sys, "platform", "win32") + with patch.object(pch, "gcc_version", return_value=(14, 2, 0)): + assert not pch.pch_usable(("g++",)) + assert "GCC 14.2.0 cannot load a precompiled header on Windows" in caplog.text + with patch.object(pch, "gcc_version", return_value=(14, 4, 0)): + assert pch.pch_usable(("g++",)) + # The bug is GCC's; another compiler is not held to its table + with patch.object(pch, "gcc_version", return_value=None): + assert pch.pch_usable(("clang++",)) + # The knob overrides the rule both ways + with patch.object(pch, "gcc_version", side_effect=AssertionError("forced")): + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + assert pch.pch_usable(("g++",)) + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert not pch.pch_usable(("g++",)) + + +def test_ccache_pch_env_enabled() -> None: + with patch.dict(os.environ, {}, clear=True): + env = pch.ccache_pch_env() + assert env == { + "CCACHE_SLOPPINESS": "pch_defines,time_macros", + "CCACHE_PCH_EXTSUM": "true", + } + + +def test_ccache_pch_env_keeps_user_values() -> None: + """A user sloppiness list without the pch entries would stop ccache + from caching every compile that loads the .gch.""" + user = {"CCACHE_SLOPPINESS": "locale, time_macros", "CCACHE_PCH_EXTSUM": "false"} + with patch.dict(os.environ, user, clear=True): + assert pch.ccache_pch_env() == { + "CCACHE_SLOPPINESS": "locale,time_macros,pch_defines" + } + + +def test_ccache_pch_env_disabled() -> None: + with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True): + assert pch.ccache_pch_env() == {} + + +def test_pch_header_text_preserves_order() -> None: + text = pch.pch_header_text(["b.h", "a.h"]) + assert text == '#include "b.h"\n#include "a.h"\n' + + +def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None: + """Sibling includes resolve against the includer's directory first, + full paths against the src root; unresolvable names end the walk.""" + _write(tmp_path, "esphome/components/x/a.h", '#include "b.h"\n') + _write( + tmp_path, + "esphome/components/x/b.h", + '#include "esphome/core/deep.h"\n#include \n#include "missing.h"\n', + ) + _write(tmp_path, "esphome/core/deep.h", "") + closure = pch._include_closure(tmp_path, ["esphome/components/x/a.h"]) + assert sorted(closure) == [ + "esphome/components/x/a.h", + "esphome/components/x/b.h", + "esphome/core/deep.h", + ] + + +def test_include_closure_handles_cycles(tmp_path: Path) -> None: + _write(tmp_path, "a.h", '#include "b.h"\n') + _write(tmp_path, "b.h", '#include "a.h"\n') + assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h", "b.h"] + + +def test_include_closure_blocks_parent_escape(tmp_path: Path) -> None: + _write(tmp_path / "src", "a.h", '#include "../outside.h"\n') + (tmp_path / "outside.h").write_text("") + assert sorted(pch._include_closure(tmp_path / "src", ["a.h"])) == ["a.h"] + + +def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: + """A transitive header edit or an extra-identity change must change the + digest; unrelated files must not.""" + _write(tmp_path, "root.h", '#include "nested.h"\n') + _write(tmp_path, "nested.h", "int a;\n") + _write(tmp_path, "unrelated.h", "int u;\n") + base = pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + assert base != pch.pch_checksum(tmp_path, ["root.h"], ["other-id"]) + _write(tmp_path, "unrelated.h", "int changed;\n") + assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + _write(tmp_path, "nested.h", "int b;\n") + assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + + +@pytest.mark.parametrize( + "platform", ["bk72xx", "esp32", "esp8266", "ln882x", "rp2", "rtl87xx"] +) +def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + assert pch.pch_script_enabled() + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert not pch.pch_script_enabled() + + +@pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_EXCLUDED_PLATFORMS)) +def test_pch_script_excluded_platform(platform: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + assert not pch.pch_script_enabled() + + +def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None: + """An angle include resolving under src/ must enter the digest; one + that does not simply ends the walk.""" + _write(tmp_path, "a.h", "#include \n#include \n") + (tmp_path / "local.h").write_text("") + closure = pch._include_closure(tmp_path, ["a.h"]) + assert set(closure) == {"a.h", "local.h"} diff --git a/tests/unit_tests/build_helpers/test_tool_runner.py b/tests/unit_tests/build_helpers/test_tool_runner.py new file mode 100644 index 00000000000..e2c7e44341a --- /dev/null +++ b/tests/unit_tests/build_helpers/test_tool_runner.py @@ -0,0 +1,338 @@ +"""Tests for esphome.build_helpers.tool_runner.""" + +# pylint: disable=protected-access + +import io +import os +from pathlib import Path +import sys +import threading +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import tool_runner +from esphome.build_helpers.tool_runner import ( + UTF8_CODEPAGE, + ToolOutput, + Utf8Console, + run_build_tool, +) +from esphome.core import CORE + +FILTER = [r"-- Component paths:", r"\s*$"] + + +def _child(code: str) -> list[str]: + return [sys.executable, "-c", code] + + +def _run( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + code: str, + **kwargs: object, +) -> tuple[int, str]: + rc = run_build_tool(_child(code), cwd=tmp_path, env=dict(os.environ), **kwargs) + # The child's print() ends lines with \r\n on Windows. + return rc, capsys.readouterr().out.replace("\r\n", "\n") + + +def test_run_build_tool_filters_and_returns_exit_code( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rc, out = _run( + capsys, + tmp_path, + "import sys\n" + "print('-- Component paths: /a /b')\n" + "print('')\n" + "print('Compiling main.cpp')\n" + "sys.exit(3)", + filter_lines=FILTER, + ) + assert rc == 3 + assert out == "Compiling main.cpp\n" + + +def test_run_build_tool_passes_everything_without_filter( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rc, out = _run(capsys, tmp_path, "print('-- Component paths: /a')\nprint('')") + assert rc == 0 + assert out == "-- Component paths: /a\n\n" + + +def test_run_build_tool_merges_stderr_and_runs_in_cwd( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + _, out = _run( + capsys, + tmp_path, + "import os, sys\nprint(os.getcwd(), flush=True)\nprint('oops', file=sys.stderr)", + filter_lines=FILTER, + ) + assert out.splitlines() == [os.path.realpath(tmp_path), "oops"] + + +def test_run_build_tool_drains_a_partial_line( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """A tool that dies mid line still shows that line, terminated.""" + rc, out = _run( + capsys, + tmp_path, + "import sys\nsys.stdout.write('ld returned 1 exit status')\nsys.exit(1)", + filter_lines=FILTER, + ) + assert rc == 1 + assert out == "ld returned 1 exit status\n" + + +def test_run_build_tool_replaces_invalid_utf8( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + _, out = _run( + capsys, + tmp_path, + # A multi-byte character split across writes must survive too. + "import sys\nb = sys.stdout.buffer\n" + "b.write(b'\\xc3'); b.flush(); b.write(b'\\xa9 ok\\n\\xff bad\\n')", + filter_lines=FILTER, + ) + assert out == "é ok\n� bad\n" + + +def test_run_build_tool_streams_before_the_tool_exits(tmp_path: Path) -> None: + """Output must reach the user while the tool runs, not when it ends.""" + seen = threading.Event() + lines: list[str] = [] + + class _Out(io.StringIO): + def write(self, s: str) -> int: + lines.append(s) + if "first" in s: + seen.set() + return len(s) + + release = tmp_path / "release" + code = ( + "import os, time\nprint('first', flush=True)\n" + f"while not os.path.exists({str(release)!r}): time.sleep(0.02)\n" + ) + with patch.object(tool_runner.sys, "stdout", _Out()): + thread = threading.Thread( + target=run_build_tool, + args=(_child(code),), + kwargs={"cwd": tmp_path, "env": dict(os.environ), "filter_lines": FILTER}, + ) + thread.start() + try: + assert seen.wait(30) + finally: + release.touch() + thread.join(30) + assert "first" in "".join(lines) + + +def _tool_output(progress: bool = True) -> tuple[ToolOutput, io.StringIO]: + out = io.StringIO() + return ToolOutput(out, FILTER, progress), out + + +def test_tool_output_collapses_progress_lines() -> None: + """Ninja status lines overwrite each other, as idf.py shows them.""" + output, out = _tool_output() + output.write("[1/2] Building a.o\n[2/2] Linking app\nwarning: x\n") + assert out.getvalue() == ( + "\r[1/2] Building a.o\x1b[K\r[2/2] Linking app\x1b[K" + + os.linesep + + "warning: x\n" + ) + + +def test_tool_output_drain_ends_a_progress_line() -> None: + output, out = _tool_output() + output.write("[1/1] Linking app\n") + output.drain() + assert out.getvalue() == "\r[1/1] Linking app\x1b[K" + os.linesep + # Nothing is pending any more. + output.drain() + assert out.getvalue().count(os.linesep) == 1 + + +def test_tool_output_drain_survives_a_broken_stream() -> None: + output, out = _tool_output() + output.write("[1/1] Linking app\n") + out.close() + output.drain() + + +def test_tool_output_filters_a_matching_progress_line() -> None: + out = io.StringIO() + output = ToolOutput(out, [r"\[1/2\]"], True) + output.write("[1/2] hidden\n[2/2] shown\n") + assert out.getvalue() == "\r[2/2] shown\x1b[K" + + +def test_tool_output_without_progress_keeps_status_lines() -> None: + output, out = _tool_output(progress=False) + output.write("[1/2] Building a.o\n") + assert out.getvalue() == "[1/2] Building a.o\n" + + +def test_tool_output_escapes_colors_for_the_dashboard() -> None: + CORE.dashboard = True + output, out = _tool_output() + output.write("[1/1] \x1b[1mLinking\x1b[0m\n") + assert "\x1b" not in out.getvalue() + assert "\\033[K" in out.getvalue() + + +@pytest.mark.parametrize( + ("width", "text", "expected"), + [ + (0, "x" * 50, "x" * 50), + (3, "abcdef", "..."), + (20, "short", "short"), + (11, "abcdefghijklmnop", "abcd...mnop"), + ], + ids=["unknown", "tiny", "fits", "elided"], +) +def test_fit_terminal(width: int, text: str, expected: str) -> None: + with patch.object( + tool_runner.shutil, + "get_terminal_size", + return_value=os.terminal_size((width, 24)), + ): + assert tool_runner._fit_terminal(text) == expected + + +class _FakeKernel32: + """Stand-in for the Windows kernel32 console code page calls.""" + + def __init__(self, input_cp: int, output_cp: int) -> None: + self.input_cp = input_cp + self.output_cp = output_cp + self.calls: list[tuple[str, int]] = [] + + def GetConsoleCP(self) -> int: # noqa: N802 + return self.input_cp + + def GetConsoleOutputCP(self) -> int: # noqa: N802 + return self.output_cp + + def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleCP", codepage)) + self.input_cp = codepage + return 1 + + def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleOutputCP", codepage)) + self.output_cp = codepage + return 1 + + +def test_run_build_tool_switches_the_console_to_utf8( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """An attached console runs the tool on UTF-8 and is then put back.""" + kernel32 = _FakeKernel32(850, 850) + with patch.object(tool_runner, "_get_kernel32", return_value=kernel32): + _run(capsys, tmp_path, "print('x')") + assert kernel32.calls == [ + ("SetConsoleCP", UTF8_CODEPAGE), + ("SetConsoleOutputCP", UTF8_CODEPAGE), + ("SetConsoleCP", 850), + ("SetConsoleOutputCP", 850), + ] + + +def test_utf8_console_restores_after_an_error() -> None: + kernel32 = _FakeKernel32(437, 437) + with pytest.raises(RuntimeError), Utf8Console(kernel32): + raise RuntimeError + assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) + + +def test_utf8_console_restores_when_the_switch_fails_part_way() -> None: + kernel32 = _FakeKernel32(850, 850) + + def _refuse(codepage: int) -> int: + kernel32.calls.append(("SetConsoleOutputCP", codepage)) + return 0 + + kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] + with Utf8Console(kernel32): + pass + assert kernel32.input_cp == 850 + assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] + + +@pytest.mark.parametrize( + "codepages", [(0, 0), (UTF8_CODEPAGE, UTF8_CODEPAGE)], ids=["none", "utf8"] +) +def test_utf8_console_leaves_the_console_alone(codepages: tuple[int, int]) -> None: + """No console, or one already on UTF-8 (an overlapping build), is untouched.""" + kernel32 = _FakeKernel32(*codepages) + with Utf8Console(kernel32): + pass + assert kernel32.calls == [] + + +def test_utf8_console_without_kernel32() -> None: + with Utf8Console(None): + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") +def test_get_kernel32_is_none_off_windows() -> None: + assert tool_runner._get_kernel32() is None + + +def test_tool_output_collapses_progress_without_a_filter() -> None: + """Progress mode splits lines on its own; it does not need a filter.""" + out = io.StringIO() + output = ToolOutput(out, None, True) + output.write("[1/1] Linking app\ndone\n") + assert out.getvalue() == "\r[1/1] Linking app\x1b[K" + os.linesep + "done\n" + + +def test_run_build_tool_flushes_a_truncated_character( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """Output that ends inside a multi-byte character still shows up.""" + _, out = _run( + capsys, + tmp_path, + "import sys\nsys.stdout.buffer.write(b'end \\xc3')", + filter_lines=FILTER, + ) + assert out == "end �\n" + + +def test_get_kernel32_loads_it_on_windows(monkeypatch: pytest.MonkeyPatch) -> None: + kernel32 = object() + fake_ctypes = SimpleNamespace(windll=SimpleNamespace(kernel32=kernel32)) + monkeypatch.setattr(tool_runner.sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes) + assert tool_runner._get_kernel32() is kernel32 + + +def test_run_build_tool_logs_the_unfiltered_output( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """The log gets every line, filtered or not, for idf.py's hint matcher.""" + log = tmp_path / "log" / "ninja_all_output.log" + _, out = _run( + capsys, + tmp_path, + "print('-- Component paths: /a')\nprint('\\x1b[1merror:\\x1b[0m boom')", + filter_lines=FILTER, + log_path=log, + ) + assert out == "\x1b[1merror:\x1b[0m boom\n" + assert log.read_text(encoding="utf-8").replace("\r\n", "\n") == ( + "-- Component paths: /a\nerror: boom\n" + ) diff --git a/tests/unit_tests/cli/__init__.py b/tests/unit_tests/cli/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/cli/test_rename.py b/tests/unit_tests/cli/test_rename.py new file mode 100644 index 00000000000..944d1e37ca9 --- /dev/null +++ b/tests/unit_tests/cli/test_rename.py @@ -0,0 +1,839 @@ +"""Tests for ``esphome rename``.""" + +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from pathlib import Path +import sys +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from pytest import CaptureFixture + +from esphome.cli.rename import command_rename +from esphome.const import CONF_ESPHOME, CONF_NAME, CONF_SUBSTITUTIONS +from esphome.core import CORE + + +@dataclass +class MockArgs: + name: str | None = None + dashboard: bool = False + + +def setup_core(tmp_path: Path, config: dict[str, Any] | None = None) -> None: + """Point CORE at a config in ``tmp_path``; the tests override the path.""" + CORE.config = config or {} + CORE.config_path = tmp_path / "test.yaml" + CORE.name = "test" + + +@pytest.fixture +def mock_run_external_process() -> Generator[Mock]: + """The child esphome the command starts to validate and install.""" + with patch("esphome.cli.rename.run_external_process") as mock: + mock.return_value = 0 + yield mock + + +def test_command_rename_invalid_characters( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with invalid characters in name.""" + setup_core(tmp_path=tmp_path) + + # Test with invalid character (space) + args = MockArgs(name="invalid name") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "invalid character" in captured.out.lower() + + +def test_command_rename_complex_yaml( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with complex YAML that cannot be renamed.""" + config_file = tmp_path / "test.yaml" + config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + args = MockArgs(name="newname") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "complex yaml" in captured.out.lower() + + +def test_command_rename_success( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test successful rename of a simple configuration.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s + +wifi: + ssid: "test" + password: "test1234" +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # Simulate successful validation and upload + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify new file was created + new_file = tmp_path / "newname.yaml" + assert new_file.exists() + + # Verify old file was removed + assert not config_file.exists() + + # Verify content was updated + content = new_file.read_text() + assert ( + 'name: "newname"' in content + or "name: 'newname'" in content + or "name: newname" in content + ) + + captured = capfd.readouterr() + assert "SUCCESS" in captured.out + + +def test_command_rename_with_substitutions( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename with substitutions in YAML.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + + args = MockArgs(name="newname", dashboard=False) + + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify substitution was updated + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + assert 'device_name: "newname"' in content + + +def test_command_rename_validation_failure( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when validation fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + args = MockArgs(name="newname", dashboard=False) + + # First call for validation fails + mock_run_external_process.return_value = 1 + + result = command_rename(args, {}) + + assert result == 1 + + # Verify new file was created but then removed due to failure + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Verify old file still exists (not removed on failure) + assert config_file.exists() + + captured = capfd.readouterr() + assert "Rename failed" in captured.out + + +def test_command_rename_install_failure_reverts( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when the install (esphome run) step fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # First call (config validation) succeeds; second (esphome run) fails. + mock_run_external_process.side_effect = [0, 1] + + result = command_rename(args, {}) + + assert result == 1 + + # New file was unlinked when install failed. + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Old file is preserved so the device stays reachable under the + # original hostname. + assert config_file.exists() + + +def test_command_rename_target_exists_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the target filename already exists. + + Without this guard, the rename would overwrite the unrelated + device's YAML and OTA-install our firmware to the wrong device. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + target_file = tmp_path / "newname.yaml" + target_file.write_text(""" +esphome: + name: someoneelse + +esp32: + board: nodemcu-32s +""") + target_original = target_file.read_text() + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + # No subprocess work happened — refusal is up-front. + mock_run_external_process.assert_not_called() + # Target file untouched: same content, still on disk. + assert target_file.exists() + assert target_file.read_text() == target_original + # Source file untouched. + assert config_file.exists() + + captured = capfd.readouterr() + assert "already exists" in captured.out + + +def test_command_rename_same_name_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the new name matches the current name. + + A same-name rename would otherwise re-write the YAML and queue + a redundant compile + install — wasted work the user almost + certainly didn't intend. + """ + config_file = tmp_path / "samename.yaml" + config_file.write_text(""" +esphome: + name: samename + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}} + + args = MockArgs(name="samename", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # File preserved verbatim — no rewrite happened. + assert config_file.exists() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_does_not_touch_friendly_name_substring( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + r"""Test rename does not match the ``name:`` substring of ``friendly_name:``. + + Without anchoring the regex at line start, the pattern + ``\s*name:\s+`` could match the trailing ``name:`` + substring inside ``friendly_name: ``. The rewrite would + flip both lines to the new name, leaving the user with a + silently corrupted ``friendly_name``. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + friendly_name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + # esphome.name swapped. + assert 'name: "newname"' in content + # friendly_name kept verbatim. + assert "friendly_name: oldname" in content + + +def test_command_rename_does_not_match_old_name_as_value_prefix( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + r"""Test rename does not match ``old_name`` as a prefix of a longer value. + + With ``old_name = kitchen`` the value ``kitchen2`` (a sensor + or wifi entry) would otherwise match the unanchored + ``["']?kitchen["']?`` pattern at the prefix and get + rewritten to the new name. The end-of-value lookahead keeps + the match restricted to whole tokens. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s + +wifi: + ap: + ssid: kitchen2 +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + assert 'name: "garage"' in content + # The wifi ssid value is unrelated and stays intact. + assert "ssid: kitchen2" in content + + +def test_command_rename_same_resolved_name_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when ``new_name`` matches the resolved device name. + + The path-equality check only catches the case where the + config filename matches the device name. For a config whose + filename and ``esphome.name`` differ (here ``weird-file.yaml`` + holds ``esphome.name: kitchen``), running + ``esphome rename weird-file.yaml kitchen`` would otherwise + fall through to the rewrite + install: the YAML's name stays + ``kitchen``, the file is renamed to ``kitchen.yaml``, and the + device gets a redundant flash. Refuse up-front so the + "already the device's name" message matches reality. + """ + config_file = tmp_path / "weird-file.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="kitchen", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # Source file untouched, no derived target written. + assert config_file.exists() + assert not (tmp_path / "kitchen.yaml").exists() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_target_path_equals_source_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the new path resolves to the source file. + + Reachable only when the YAML's filename and ``esphome.name`` + disagree — here ``kitchen.yaml`` holds ``esphome.name: garage`` + and the user runs ``esphome rename kitchen.yaml kitchen``. The + name-equality check above passes (``garage != kitchen``), but + ``/kitchen.yaml`` resolves to the source file + itself, so the rewrite would clobber the source mid-rename. + Refuse rather than silently overwriting. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: garage + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}} + + args = MockArgs(name="kitchen", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # Source file still present and unmodified. + assert config_file.exists() + assert "name: garage" in config_file.read_text() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_does_not_touch_lookalike_name_in_other_blocks( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename only swaps the esphome.name line. + + A device whose name happens to match a sensor's / output's + ``name:`` value must not have those other names rewritten — + they're independent. Without an anchor for the esphome block + a naive regex would clobber every line whose value matches. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s + +sensor: + - platform: template + name: kitchen + lambda: 'return 0;' +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + # esphome.name renamed. + assert 'name: "garage"' in content + # Sensor's name is the user's entity name — must not be touched. + assert " name: kitchen\n" in content + + +def test_command_rename_preserves_trailing_comment( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename preserves a trailing ``# comment`` on the name line.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen # primary device + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + assert "# primary device" in content + + +def test_command_rename_handles_double_quoted_value( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename matches when the existing value is double-quoted.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: "kitchen" + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + assert 'name: "garage"' in new_file.read_text() + + +def test_command_rename_handles_single_quoted_value( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename matches when the existing value is single-quoted.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: 'kitchen' + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + assert 'name: "garage"' in new_file.read_text() + + +def test_command_rename_leaves_a_lookalike_substitution_line_alone( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Only the substitution's own line changes; another block's field of + the same name and value is not it.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +example: + device_name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0 + content = (tmp_path / "newname.yaml").read_text() + assert 'device_name: "newname"' in content + assert "example:\n device_name: oldname\n" in content + + +def test_command_rename_keeps_line_endings_and_mode( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """A CRLF file stays CRLF and the new file gets the old one's mode.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_bytes( + b"esphome:\r\n name: oldname # device\r\n\r\nesp32:\r\n board: nodemcu-32s\r\n" + ) + if sys.platform != "win32": + config_file.chmod(0o600) + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0 + new_file = tmp_path / "newname.yaml" + assert new_file.read_bytes() == ( + b'esphome:\r\n name: "newname" # device\r\n\r\nesp32:\r\n board: nodemcu-32s\r\n' + ) + if sys.platform != "win32": + assert new_file.stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.parametrize( + ("yaml_text", "extra"), + [ + ("esphome:\n name: ${missing}\n", {}), + ("esphome: {name: oldname}\n", {}), + ("esphome: !include base.yaml\n", {"base.yaml": "name: oldname\n"}), + ( + ( + "named: &named\n name: oldname\n\nesphome:\n <<: *named\n\n" + "sensor:\n - platform: template\n <<: *named\n" + ), + {}, + ), + ], + ids=["missing_substitution", "flow_mapping", "included_name", "merged_name"], +) +def test_command_rename_refuses_shapes_without_a_plain_name_line( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, + yaml_text: str, + extra: dict[str, str], +) -> None: + """The name line must be a plain value in the file being renamed.""" + for name, text in extra.items(): + (tmp_path / name).write_text(text) + config_file = tmp_path / "oldname.yaml" + config_file.write_text(yaml_text) + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + mock_run_external_process.assert_not_called() + assert "complex yaml" in capfd.readouterr().out.lower() + + +def test_command_rename_removes_the_new_file_when_its_mode_cannot_be_set( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """No orphan is left for the next attempt to trip over.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + with patch("pathlib.Path.chmod", side_effect=OSError("read-only share")): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + assert not (tmp_path / "newname.yaml").exists() + mock_run_external_process.assert_not_called() + assert "Rename failed" in capfd.readouterr().out + + +def test_command_rename_refuses_a_name_without_a_source_line( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """A key the loader did not read from a file cannot be located.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + with patch("esphome.yaml_edit.source_of", return_value=None): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + mock_run_external_process.assert_not_called() + assert "was not read from" in capfd.readouterr().out + + +def test_command_rename_passes_dashboard_to_the_install( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=True), {}) == 0 + install = mock_run_external_process.call_args_list[-1].args + assert install[-6:-4] == ("--dashboard", "run") + + +def test_command_rename_interrupted_install_reverts( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + mock_run_external_process.side_effect = [0, KeyboardInterrupt] + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + assert not (tmp_path / "newname.yaml").exists() + assert config_file.exists() + + +def test_command_rename_reads_a_config_linked_from_outside( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """The source is only read; the new file lands in the config directory.""" + outside = tmp_path / "elsewhere.yaml" + outside.write_text("esphome:\n name: oldname\n") + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "oldname.yaml" + config_file.symlink_to(outside) + setup_core(tmp_path=config_dir) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0 + assert (config_dir / "newname.yaml").read_text() == 'esphome:\n name: "newname"\n' + assert not config_file.exists() + assert outside.exists() + + +def test_command_rename_reports_an_orphan_it_could_not_remove( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """The write failure is the message; a cleanup failure is added to it.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + with ( + patch("pathlib.Path.chmod", side_effect=OSError("read-only share")), + patch("pathlib.Path.unlink", side_effect=OSError("busy")), + ): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + out = capfd.readouterr().out + assert "Rename failed" in out + assert "Could not remove" in out and "newname.yaml" in out + + +def test_command_rename_install_failure_says_so( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """The device may already carry the new name; the user is told.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + mock_run_external_process.side_effect = [0, 1] + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + out = capfd.readouterr().out + assert "Rename failed: the install did not finish" in out + assert "may already run the new name" in out + + +def test_command_rename_validation_revert_reports_an_orphan( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + mock_run_external_process.return_value = 1 + with patch("pathlib.Path.unlink", side_effect=OSError("busy")): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + out = capfd.readouterr().out + assert "does not validate" in out and "Could not remove" in out diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 31297911f51..3e2f37e16c1 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -194,17 +194,17 @@ def test_superseded_device_info_fields_still_declared_in_header() -> None: def test_superseded_device_info_fields_still_encoded_and_sized() -> None: """Each superseded field must still be touched by DeviceInfoResponse's - generated encode() and calculate_size(), i.e. it is still put on the wire. + generated encode_msg() and calc_size_msg(), i.e. it is still put on the wire. """ - encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode") - size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size") + encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode_msg") + size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calc_size_msg") for field_name in SUPERSEDED_FIELDS: - assert f"this->{field_name}" in encode_body, ( - f"DeviceInfoResponse::encode() no longer references {field_name}. " + assert f"msg.{field_name}" in encode_body, ( + f"DeviceInfoResponse::encode_msg() no longer references {field_name}. " f"{DEPRECATED_FIELD_TRAP}" ) - assert f"this->{field_name}" in size_body, ( - f"DeviceInfoResponse::calculate_size() no longer references " + assert f"msg.{field_name}" in size_body, ( + f"DeviceInfoResponse::calc_size_msg() no longer references " f"{field_name}. {DEPRECATED_FIELD_TRAP}" ) @@ -380,3 +380,13 @@ def test_api_version_minor_is_at_least_15() -> None: "clients to see api_version >= 1.15 in HelloResponse before they will " "ever request it." ) + + +def test_generated_encode_calls_keep_the_cursor() -> None: + """No generated ProtoEncode call may drop the returned cursor.""" + dropped = [ + line + for line in CPP_TEXT.splitlines() + if "ProtoEncode::" in line and "pos = ProtoEncode::" not in line + ] + assert not dropped, dropped[:5] diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 797125ba8f9..2e4eb67a241 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -15,9 +15,15 @@ import pytest sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) +import aioesphomeapi.api_options_pb2 as pb # noqa: E402 from api_protobuf import ( # noqa: E402 MAX_MESSAGE_ID, + SOURCE_BOTH, + SOURCE_CLIENT, + SOURCE_SERVER, _make_ifdef_line, + build_message_type, + create_field_type_info, get_varint64_ifdef, validate_message_id, ) @@ -34,16 +40,26 @@ def _file_with_messages( file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto") for name, field_type, deprecated in messages: msg = file_desc.message_type.add(name=name) - field = msg.field.add(name="value", number=1, type=field_type) + field = msg.field.add() + field.CopyFrom(_field(field_type)) field.options.deprecated = deprecated return file_desc UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 +MESSAGE = descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE +DOUBLE = descriptor_pb2.FieldDescriptorProto.TYPE_DOUBLE INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 +INT32 = descriptor_pb2.FieldDescriptorProto.TYPE_INT32 +SINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT32 FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64 +FIXED32 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED32 +FLOAT = descriptor_pb2.FieldDescriptorProto.TYPE_FLOAT +BOOL = descriptor_pb2.FieldDescriptorProto.TYPE_BOOL +STRING = descriptor_pb2.FieldDescriptorProto.TYPE_STRING +BYTES = descriptor_pb2.FieldDescriptorProto.TYPE_BYTES def test_no_varint64_fields() -> None: @@ -107,3 +123,240 @@ def test_message_id_at_maximum_is_accepted() -> None: def test_message_id_above_maximum_is_rejected() -> None: with pytest.raises(ValueError, match="exceeds the plaintext"): validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage") + + +def _field( + field_type: int, number: int = 1, *, force: bool = False, repeated: bool = False +) -> descriptor_pb2.FieldDescriptorProto: + field = descriptor_pb2.FieldDescriptorProto( + name="value", number=number, type=field_type + ) + if repeated: + field.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED + if force: + field.options.Extensions[pb.force] = True + return field + + +def _encode_field( + field_type: int, number: int = 1, force: bool = False, repeated: bool = False +) -> str: + """Return the encode statement the generator emits for one encode-only field.""" + field = _field(field_type, number, force=force, repeated=repeated) + return create_field_type_info( + field, needs_decode=False, needs_encode=True + ).encode_content + + +SCALAR_TYPES = [ + BOOL, + UINT32, + INT32, + UINT64, + INT64, + SINT32, + FLOAT, + FIXED32, + STRING, + BYTES, +] + + +@pytest.mark.parametrize("field_type", SCALAR_TYPES) +def test_forced_fields_use_the_force_overload_or_raw_writes(field_type: int) -> None: + content = _encode_field(field_type, force=True) + assert ( + "_force(" in content + or "write_raw_byte(" in content + or "write_tag_and_fixed32(" in content + ), content + + +@pytest.mark.parametrize("field_type", [FLOAT, FIXED32]) +def test_single_byte_tag_fixed32_shares_the_outlined_writer(field_type: int) -> None: + unconditional = _encode_field(field_type, force=True) + assert unconditional.count("write_tag_and_fixed32(pos, 13,") == 1, unconditional + guarded = _encode_field(field_type, force=False) + assert guarded.startswith("if ("), guarded + assert "[[likely]]" in guarded + assert "write_tag_and_fixed32(pos, 13," in guarded + + +@pytest.mark.parametrize("field_type", [FLOAT, FIXED32]) +def test_multi_byte_tag_fixed32_falls_back_to_the_generic_helper( + field_type: int, +) -> None: + content = _encode_field(field_type, number=16) + assert "write_tag_and_fixed32" not in content, content + assert content.startswith("pos = ProtoEncode::encode_"), content + + +def _decode_case(field_type: int, number: int, *, repeated: bool = False) -> str: + """Return the decode_field() case the generator emits for one decoded field.""" + field = _field(field_type, number, repeated=repeated) + if field_type == MESSAGE: + field.type_name = ".Sub" + return create_field_type_info( + field, needs_decode=True, needs_encode=False + ).decode_content + + +@pytest.mark.parametrize( + ("needs_decode", "force", "member"), + [ + (False, False, "StringRef value{nullptr, 0}; // null until set, encode only"), + (True, False, "StringRef value{};"), + (False, True, "StringRef value{};"), + ], +) +def test_string_fields_default_to_null_only_when_never_read( + needs_decode: bool, force: bool, member: str +) -> None: + """Only a string that is neither decoded nor force encoded may start as a null StringRef.""" + ti = create_field_type_info( + _field(STRING, force=force), needs_decode=needs_decode, needs_encode=True + ) + assert ti.public_content == [member] + + +@pytest.mark.parametrize( + ("field_type", "number", "wire_type", "accessor"), + [ + (UINT32, 2, "WIRE_TYPE_VARINT", "value.as_varint()"), + (BOOL, 3, "WIRE_TYPE_VARINT", "value.as_bool()"), + (STRING, 1, "WIRE_TYPE_LENGTH_DELIMITED", "value.data()"), + (FLOAT, 4, "WIRE_TYPE_FIXED32", "value.as_float()"), + (FIXED32, 5, "WIRE_TYPE_FIXED32", "value.as_fixed32()"), + ], +) +def test_decode_cases_carry_field_number_and_wire_type( + field_type: int, number: int, wire_type: str, accessor: str +) -> None: + """Each decoded field yields one case keyed on its number and declared wire type.""" + case = _decode_case(field_type, number) + lines = case.splitlines() + assert lines[0] == f"case proto_tag({number}, {wire_type}):", case + assert accessor in lines[1], case + assert lines[-1].strip() == "break;", case + + +@pytest.mark.parametrize( + ("field_type", "repeated", "wire_type", "store"), + [ + (UINT32, True, "WIRE_TYPE_VARINT", "this->value.push_back(value.as_varint());"), + ( + STRING, + True, + "WIRE_TYPE_LENGTH_DELIMITED", + "this->value.push_back(value.as_string());", + ), + ( + MESSAGE, + False, + "WIRE_TYPE_LENGTH_DELIMITED", + "value.decode_to_message(this->value);", + ), + ( + MESSAGE, + True, + "WIRE_TYPE_LENGTH_DELIMITED", + "value.decode_to_message(this->value.back());", + ), + ], +) +def test_repeated_and_message_fields_decode_through_the_same_case_shape( + field_type: int, repeated: bool, wire_type: str, store: str +) -> None: + """Repeated and sub message fields land in the one switch with their own store.""" + case = _decode_case(field_type, 7, repeated=repeated) + lines = case.splitlines() + assert lines[0] == f"case proto_tag(7, {wire_type}):", case + assert store in case, case + if field_type == MESSAGE and repeated: + assert "this->value.emplace_back();" in case, case + assert lines[-1].strip() == "break;", case + + +def test_a_fixed64_field_fails_at_generation_time() -> None: + """The decode loop has no 64 bit wire type path, so such a field must never reach it silently.""" + desc = descriptor_pb2.DescriptorProto(name="Wide") + desc.field.add(name="ratio", number=1, type=DOUBLE) + with pytest.raises( + ValueError, match="64-bit type 'double' .*ratio.* not supported" + ): + build_message_type(desc, {}, {"Wide": SOURCE_CLIENT}) + + +def test_message_decodes_through_one_static_decode_field() -> None: + """All wire types of a decoded message land in one static decode_field() switch that the + inline decode() hands to the shared loop.""" + desc = descriptor_pb2.DescriptorProto(name="Mixed") + desc.field.add(name="name", number=1, type=STRING) + desc.field.add(name="count", number=2, type=UINT32) + desc.field.add(name="level", number=3, type=FLOAT) + header, cpp, _ = build_message_type(desc, {}, {"Mixed": SOURCE_CLIENT}) + decl = "static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar);" + assert header.count(decl) == 1 + assert ( + cpp.count( + "void Mixed::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {" + ) + == 1 + ) + assert "switch (tag) {" in cpp + assert "const ProtoFieldValue value(data, scalar);" in cpp + for number, wire_type in ( + (1, "WIRE_TYPE_LENGTH_DELIMITED"), + (2, "WIRE_TYPE_VARINT"), + (3, "WIRE_TYPE_FIXED32"), + ): + assert f"case proto_tag({number}, {wire_type}):" in cpp, cpp + # The static body works on the cast message, never on this + assert "auto &msg = *static_cast(self);" in cpp + assert "this->" not in cpp + assert ( + header.count( + "ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);" + ) + == 1 + ) + + +def test_fixed_vector_message_keeps_its_own_decode() -> None: + """A message that sizes a FixedVector first decodes through its own decode(), not the inline one.""" + desc = descriptor_pb2.DescriptorProto(name="Sized") + field = desc.field.add(name="values", number=1, type=UINT32) + field.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED + field.options.Extensions[pb.fixed_vector] = True + header, cpp, _ = build_message_type(desc, {}, {"Sized": SOURCE_CLIENT}) + assert ( + "ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);" + not in header + ) + assert header.count("void decode(const uint8_t *buffer, size_t length);") == 1 + assert "void Sized::decode(const uint8_t *buffer, size_t length) {" in cpp + assert "ProtoDecodableMessage::count_repeated_field(buffer, length, 1)" in cpp + assert ( + cpp.count( + "ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);" + ) + == 1 + ) + + +@pytest.mark.parametrize("source", [SOURCE_CLIENT, SOURCE_SERVER, SOURCE_BOTH]) +def test_repeated_strings_generate_in_every_direction(source: int) -> None: + """A repeated string builds its element type from the type map, so the null + default plumbing on the pointer string type never sees it.""" + desc = descriptor_pb2.DescriptorProto(name="Names") + desc.field.add( + name="names", + number=1, + type=STRING, + label=descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED, + ) + header, cpp, _ = build_message_type(desc, {}, {"Names": source}) + assert "std::vector names{};" in header + assert "nullptr" not in header + if source != SOURCE_SERVER: + assert "case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED):" in cpp diff --git a/tests/unit_tests/components/esp8266/test_framework_version.py b/tests/unit_tests/components/esp8266/test_framework_version.py index 0107aff8dd3..11590f77cf2 100644 --- a/tests/unit_tests/components/esp8266/test_framework_version.py +++ b/tests/unit_tests/components/esp8266/test_framework_version.py @@ -2,9 +2,14 @@ import pytest -from esphome.components.esp8266 import _arduino_check_versions +from esphome.components.esp8266 import ( + _arduino_check_versions, + _format_framework_arduino_version, + _framework_package_version, +) import esphome.config_validation as cv from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION +from esphome.core import EsphomeError def test_versions_before_3_are_rejected() -> None: @@ -21,3 +26,27 @@ def test_supported_versions_pass() -> None: value = _arduino_check_versions({CONF_VERSION: "recommended"}) assert value[CONF_VERSION] == "3.1.2" assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION] + + +def test_framework_package_version() -> None: + assert _framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" + assert _framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" + # A future major bump needs its own encoding, not a doomed registry lookup + with pytest.raises(EsphomeError, match="not supported yet"): + _framework_package_version(cv.Version(4, 0, 0)) + # Cores before 3.x cannot build ESPHome (C++20) and are rejected + with pytest.raises(EsphomeError, match="requires core 3"): + _framework_package_version(cv.Version(2, 7, 4)) + + +def test_format_framework_arduino_version_pins_all_series() -> None: + """The PlatformIO source formatter across every encoding era.""" + + assert _format_framework_arduino_version(cv.Version(3, 1, 2)) == "~3.30102.0" + # Pre-3 cores are rejected with the version line anchored + with pytest.raises(cv.Invalid, match="requires core 3"): + _format_framework_arduino_version(cv.Version(2, 7, 4)) + # Anchored to the framework version line, not a bare EsphomeError + with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: + _format_framework_arduino_version(cv.Version(4, 0, 0)) + assert excinfo.value.path == ["version"] diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py new file mode 100644 index 00000000000..e7f443c2d23 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -0,0 +1,228 @@ +"""Tests for the native (non-PlatformIO) toolchain config validation.""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.components import esp8266 +from esphome.components.esp8266 import ( + ARDUINO_FRAMEWORK_SCHEMA, + _resolve_toolchain, + _validate_native_toolchain, + _warn_platformio_toolchain, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_BOARD, + CONF_FRAMEWORK, + CONF_PLATFORM_VERSION, + CONF_SOURCE, + CONF_TOOLCHAIN, + CONF_VERSION, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP8266, + Toolchain, +) +from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType + + +@pytest.fixture(autouse=True) +def _arduino_toolchain() -> Generator[None]: + # The suite-wide reset_core fixture clears both after each test; the + # shared backend resolver reads the platform as well as the toolchain, + # and the decode-tool cache lives in CORE.data + CORE.toolchain = Toolchain.ARDUINO + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 + yield + + +def _config( + board: str = "nodemcuv2", + version: str = "3.1.2", + source: str | None = None, + platform_version: str | None = None, +) -> ConfigType: + framework: dict[str, str] = {CONF_VERSION: version} + if source is not None: + framework[CONF_SOURCE] = source + if platform_version is not None: + framework[CONF_PLATFORM_VERSION] = platform_version + # The real schema fills the source/platform_version defaults, so these + # tests validate against what config validation actually emits + return { + CONF_FRAMEWORK: ARDUINO_FRAMEWORK_SCHEMA(framework), + CONF_BOARD: board, + } + + +def test_valid_config_passes() -> None: + config = _config() + assert _validate_native_toolchain(config) is config + + +def test_platformio_toolchain_skips_checks() -> None: + # 3.0.2 is pio-legal (>= the global 3.0.0 floor) but has no native build; + # the bogus board only the native path checks + CORE.toolchain = Toolchain.PLATFORMIO + config = _config(board="not_a_board", version="3.0.2") + assert _validate_native_toolchain(config) is config + + +def test_version_without_build_rejected() -> None: + """Only the core versions built in esphome-libs/arduino-esp8266 work.""" + with pytest.raises( + cv.Invalid, match=r"3\.1\.1.*available: 3\.1\.2.*platformio" + ) as excinfo: + _validate_native_toolchain(_config(version="3.1.1")) + assert excinfo.value.path == [CONF_FRAMEWORK, CONF_VERSION] + + +def test_built_version_accepted() -> None: + _validate_native_toolchain(_config(version="3.1.2")) + + +def test_custom_platform_version_warns_and_is_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + config = _config(platform_version="platformio/espressif8266@4.0.1") + _validate_native_toolchain(config) + assert "'platform_version' is ignored" in caplog.text + assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK] + + +def test_default_platform_version_does_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + config = _config() + _validate_native_toolchain(config) + assert "'platform_version' is ignored" not in caplog.text + assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK] + + +def test_custom_source_rejected() -> None: + with pytest.raises(cv.Invalid, match="custom framework source"): + _validate_native_toolchain( + _config(source="https://github.com/esp8266/Arduino.git") + ) + + +def test_unsupported_board_rejected() -> None: + with pytest.raises(cv.Invalid, match="not supported by"): + _validate_native_toolchain(_config(board="not_a_board")) + + +def test_yaml_toolchain_key_resolves() -> None: + """The documented `toolchain: arduino` YAML key selects the native path.""" + CORE.toolchain = None + _resolve_toolchain({CONF_TOOLCHAIN: Toolchain.ARDUINO}) + assert CORE.toolchain == Toolchain.ARDUINO + assert CORE.using_toolchain_arduino + + +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + (None, Toolchain.ARDUINO), + # An explicit `toolchain:` still wins over the default + (Toolchain.PLATFORMIO, Toolchain.PLATFORMIO), + (Toolchain.ARDUINO, Toolchain.ARDUINO), + ], +) +def test_default_toolchain_is_arduino( + config_toolchain: Toolchain | None, expected: Toolchain +) -> None: + CORE.toolchain = None + config = {} if config_toolchain is None else {CONF_TOOLCHAIN: config_toolchain} + _resolve_toolchain(config) + assert CORE.toolchain == expected + + +def test_decode_pc_native_missing_tools_warns_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A stack dump of many addresses produces one missing-tool warning.""" + + with ( + patch( + "esphome.arduino8266.toolchain.get_addr2line_path", + return_value=tmp_path / "missing-addr2line", + ), + patch( + "esphome.arduino8266.toolchain.get_elf_path", + return_value=tmp_path / "missing.elf", + ), + ): + esp8266._decode_pc({}, "40201234") + esp8266._decode_pc({}, "40201238") + assert caplog.text.count("Cannot decode crash addresses") == 1 + + +def test_decode_pc_platformio_missing_tools_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The PlatformIO branch reports a missing addr2line/ELF at the same + warning level as the native one; raw undecoded addresses with no + stated reason are undiagnosable at default log level.""" + + CORE.toolchain = Toolchain.PLATFORMIO + idedata = SimpleNamespace(addr2line_path=None, firmware_elf_path=None) + with patch("esphome.platformio.toolchain.get_idedata", return_value=idedata): + esp8266._decode_pc({}, "40201234") + esp8266._decode_pc({}, "40201238") + assert caplog.text.count("Cannot decode crash addresses") == 1 + + +def test_resolve_toolchain_rejects_unsupported() -> None: + """ESP8266 rejects a CLI toolchain it cannot serve, like every platform.""" + + CORE.toolchain = Toolchain.SDK_NRF + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'sdk-nrf'"): + _resolve_toolchain({}) + + +def test_run_compile_platformio_falls_through() -> None: + """Under toolchain: platformio the hook returns False without touching + the native backend; this is what keeps existing users on PlatformIO.""" + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.arduino8266.toolchain.run_compile") as mock_native: + assert esp8266.run_compile(SimpleNamespace(), {}) is False + mock_native.assert_not_called() + + +def test_run_compile_arduino_failure_raises() -> None: + """A non-zero native build fails by name instead of returning success.""" + CORE.verbose = False + with ( + patch("esphome.arduino8266.toolchain.run_compile", return_value=1), + pytest.raises(EsphomeError, match="native build failed"), + ): + esp8266.run_compile(SimpleNamespace(), {}) + + +def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None: + """The native build writes no PlatformIO extra scripts.""" + CORE.build_path = tmp_path + esp8266.copy_files() + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize( + ("toolchain", "warns"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ARDUINO, False)], +) +def test_platformio_toolchain_deprecation_warning( + toolchain: Toolchain, warns: bool, caplog: pytest.LogCaptureFixture +) -> None: + CORE.toolchain = toolchain + config = _config() + assert _warn_platformio_toolchain(config) is config + assert ( + "deprecated and will be removed in ESPHome 2027.4.0" in caplog.text + ) is warns diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db39..727a4c8c1ef 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( diff --git a/tests/unit_tests/components/host/__init__.py b/tests/unit_tests/components/host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/host/test_init.py b/tests/unit_tests/components/host/test_init.py new file mode 100644 index 00000000000..d7662e0b0a2 --- /dev/null +++ b/tests/unit_tests/components/host/test_init.py @@ -0,0 +1,86 @@ +"""Tests for the host platform's native-toolchain wiring.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import pytest + +from esphome.components import host +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + Toolchain, +) +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _core_data() -> None: + CORE.data[KEY_CORE] = {} + + +def test_schema_resolves_the_host_toolchain() -> None: + """The host serves exactly one toolchain; it is picked without config.""" + config = host.CONFIG_SCHEMA({}) + assert CORE.toolchain is Toolchain.HOST + assert CORE.using_native_toolchain + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == PLATFORM_HOST + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "host" + assert "mac_address" in config + + +def test_schema_rejects_the_platformio_toolchain() -> None: + """There is no PlatformIO backend left to honor --toolchain platformio.""" + CORE.toolchain = Toolchain.PLATFORMIO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'platformio' for host"): + host.CONFIG_SCHEMA({}) + + +def test_to_code_configures_the_native_build() -> None: + config = host.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF"}) + asyncio.run(host.to_code(config)) + assert "-DUSE_HOST" in CORE.build_flags + # The standard travels through cpp_standard, not a -std= build flag, + # so the generator can keep it off the C compile line + assert CORE.cpp_standard == "gnu++20" + assert not any(flag.startswith("-std=") for flag in CORE.build_flags) + assert not CORE.platformio_options + define_names = {define.name for define in CORE.defines} + assert {"ESPHOME_BOARD", "ESPHOME_VARIANT", "USE_ESPHOME_HOST_MAC_ADDRESS"} <= ( + define_names + ) + + +def test_run_compile_hook_claims_the_build() -> None: + config = {"esphome": {}} + with ( + patch.object(host.sys, "platform", "linux"), + patch("esphome.host.toolchain.run_compile", return_value=0) as run, + ): + assert host.run_compile(object(), config) is True + run.assert_called_once_with(config, CORE.verbose) + + +def test_run_compile_hook_raises_on_failure() -> None: + with ( + patch.object(host.sys, "platform", "linux"), + patch("esphome.host.toolchain.run_compile", return_value=2), + pytest.raises(EsphomeError, match="Host build failed"), + ): + host.run_compile(object(), {"esphome": {}}) + + +def test_run_compile_hook_refuses_windows() -> None: + """The host sources use POSIX interfaces; fail before building, by name.""" + with ( + patch.object(host.sys, "platform", "win32"), + patch("esphome.host.toolchain.run_compile") as run, + pytest.raises(EsphomeError, match="does not build on Windows"), + ): + host.run_compile(object(), {"esphome": {}}) + run.assert_not_called() diff --git a/tests/unit_tests/components/light/test_automation.py b/tests/unit_tests/components/light/test_automation.py new file mode 100644 index 00000000000..6a2a05227d3 --- /dev/null +++ b/tests/unit_tests/components/light/test_automation.py @@ -0,0 +1,58 @@ +"""Tests for validate_light_state -- a light on/off value that prioritizes ON/OFF +string forms over generic boolean forms, while still accepting the latter.""" + +import pytest +import yaml + +from esphome.components.light.automation import validate_light_state +import esphome.config_validation as cv +from esphome.schema_extractors import SCHEMA_EXTRACT + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("ON", True), + ("on", True), + ("On", True), + ("OFF", False), + ("off", False), + ("Off", False), + (True, True), + (False, False), + ("true", True), + ("false", False), + ("yes", True), + ("no", False), + ], +) +def test_validate_light_state_accepts_on_off_and_booleans( + value: str | bool, expected: bool +) -> None: + assert validate_light_state(value) is expected + + +def test_validate_light_state_rejects_invalid_string() -> None: + # The error must mention both accepted forms (ON/OFF and boolean), not just + # whichever validator happened to run last. + with pytest.raises(cv.Invalid, match="ON.*OFF.*boolean"): + validate_light_state("maybe") + + +def test_validate_light_state_schema_extractor_reports_on_off() -> None: + assert validate_light_state(SCHEMA_EXTRACT) == ("ON", "OFF") + + +def test_validate_light_state_quoted_on_off_survive_yaml_parsing() -> None: + """The default PyYAML resolver treats bareword on/off/yes/no as booleans, so a real + YAML config must quote 'ON'/'OFF' for validate_light_state's string-matching branch + to ever see a string at all -- an unquoted `state: on` already arrives as a native + bool. Both forms must still validate to the same result. + """ + quoted = yaml.safe_load('state: "ON"')["state"] + assert quoted == "ON" + assert validate_light_state(quoted) is True + + unquoted = yaml.safe_load("state: on")["state"] + assert unquoted is True # PyYAML already converted it before validation runs + assert validate_light_state(unquoted) is True diff --git a/tests/unit_tests/components/light/test_restore_state.py b/tests/unit_tests/components/light/test_restore_state.py new file mode 100644 index 00000000000..aee3c5ec194 --- /dev/null +++ b/tests/unit_tests/components/light/test_restore_state.py @@ -0,0 +1,535 @@ +"""Tests for the restore_mode/restore_state -> runtime lambda translation layer.""" + +import logging + +import pytest + +import esphome.codegen as cg +from esphome.components.light import ( + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + LIGHT_SCHEMA, + LightType, + _final_validate, + light_schema, +) +from esphome.components.light.restore_state import ( + _RESTORE_STATE_FIELDS_SCHEMA, + LEGACY_RESTORE_MODES, + RESTORE_STATE_INITIAL, + RESTORE_STATE_INVERT, + RESTORE_STATE_KEEP, + RESTORE_STATE_NONE, + RESTORE_STATE_SCHEMA, + StateStatement, + _initial_state_overridden_by_legacy_mode, + _initial_state_statements, + _legacy_cold_boot_statements, + _partition_state_statements, + _restore_state_statements, + _validate_restore_state_state, +) +from esphome.components.light.types import ColorMode +import esphome.config_validation as cv +from esphome.const import CONF_STATE +from esphome.core import Lambda +from esphome.schema_extractors import SCHEMA_EXTRACT + +# (mode name, expected cold_boot_state, expected restore_action, expected save_enabled) +LEGACY_MODE_TABLE = [ + ("RESTORE_DEFAULT_OFF", False, None, True), + ("RESTORE_DEFAULT_ON", True, None, True), + ("ALWAYS_OFF", False, None, False), + ("ALWAYS_ON", True, None, False), + ("RESTORE_INVERTED_DEFAULT_OFF", False, "INVERT", True), + ("RESTORE_INVERTED_DEFAULT_ON", True, "INVERT", True), + ("RESTORE_AND_OFF", False, False, True), + ("RESTORE_AND_ON", True, True, True), +] + + +@pytest.mark.parametrize( + ("mode", "cold_boot_state", "restore_action", "save_enabled"), LEGACY_MODE_TABLE +) +def test_legacy_restore_mode_translation( + mode: str, cold_boot_state: bool, restore_action, save_enabled: bool +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert legacy.cold_boot_state is cold_boot_state + assert legacy.restore_action == restore_action + assert legacy.save_enabled is save_enabled + + +def test_all_eight_legacy_modes_present() -> None: + assert set(LEGACY_RESTORE_MODES) == {mode for mode, *_ in LEGACY_MODE_TABLE} + + +def test_restore_mode_and_restore_state_are_exclusive() -> None: + with pytest.raises(cv.Invalid, match="restore"): + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: {}, + } + ) + + +def test_neither_restore_key_required_or_defaulted() -> None: + config = LIGHT_SCHEMA({"name": "test"}) + assert CONF_RESTORE_MODE not in config + assert CONF_RESTORE_STATE not in config + + +def test_restore_state_empty_config_keeps_everything() -> None: + config = RESTORE_STATE_SCHEMA({}) + assert all(value == RESTORE_STATE_KEEP for value in config.values()) + + +@pytest.mark.parametrize("value", ["all", "All", "ALL"]) +def test_restore_state_all_shorthand_is_case_insensitive(value: str) -> None: + assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_SCHEMA({}) + + +def test_restore_state_rejects_other_strings() -> None: + with pytest.raises(cv.Invalid): + RESTORE_STATE_SCHEMA("everything") + + +_DummyLight = cg.esphome_ns.class_("DummyLight") + + +def test_default_restore_mode_applies_when_neither_key_given() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + config = schema({"id": "light1"}) + assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON" + assert CONF_RESTORE_STATE not in config + + +def test_default_restore_mode_is_dropped_when_restore_state_given() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + config = schema({"id": "light1", "restore_state": {"state": "INVERT"}}) + assert CONF_RESTORE_MODE not in config + assert config[CONF_RESTORE_STATE][CONF_STATE] == "INVERT" + + +def test_default_restore_mode_still_exclusive_with_explicit_restore_state() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + with pytest.raises(cv.Invalid, match="restore"): + schema( + { + "id": "light1", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: {}, + } + ) + + +def test_default_restore_mode_result_still_extendable() -> None: + # light_schema() must keep returning a real cv.Schema (not e.g. cv.All) even when + # default_restore_mode is given, since every in-tree light platform chains + # .extend() on its result. + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + extended = schema.extend({}) + config = extended({"id": "light1"}) + assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON" + + +@pytest.mark.parametrize("value", ["none", "None", "NONE"]) +def test_restore_state_none_shorthand_is_case_insensitive(value: str) -> None: + assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_NONE + + +def test_restore_state_none_is_still_exclusive_with_restore_mode() -> None: + # cv.Exclusive checks which keys are present, regardless of their resolved + # value, so restore_state: none must still conflict with restore_mode:. + with pytest.raises(cv.Invalid, match="restore"): + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: "none", + } + ) + + +def test_restore_state_explicit_overrides_leave_others_keep() -> None: + config = RESTORE_STATE_SCHEMA({"state": "invert", "brightness": "100%"}) + assert config["state"] == "INVERT" + assert config["brightness"] == pytest.approx(1.0) + assert config["color_mode"] == RESTORE_STATE_KEEP + assert config["red"] == RESTORE_STATE_KEEP + + +@pytest.mark.parametrize("value", ["keep", "Keep", "KEEP"]) +def test_restore_state_state_accepts_keep_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == RESTORE_STATE_KEEP + + +@pytest.mark.parametrize("value", ["invert", "Invert", "INVERT"]) +def test_restore_state_state_accepts_invert_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INVERT" + + +@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"]) +def test_restore_state_state_accepts_initial_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INITIAL" + + +@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"]) +def test_restore_state_other_fields_accept_initial_case_insensitively( + value: str, +) -> None: + assert RESTORE_STATE_SCHEMA({"brightness": value})["brightness"] == "INITIAL" + + +@pytest.mark.parametrize( + ("value", "expected"), [("ON", True), ("OFF", False), (True, True), (False, False)] +) +def test_restore_state_state_prioritizes_on_off( + value: str | bool, expected: bool +) -> None: + # A quoted "ON"/"OFF" string, distinct from KEEP/INVERT, still resolves via + # validate_light_state -- matching initial_state:'s own state field. + assert RESTORE_STATE_SCHEMA({"state": value})["state"] is expected + + +def test_validate_restore_state_state_schema_extract_reports_all_options() -> None: + # Regression test: SCHEMA_EXTRACT is an object() sentinel, not a str, so a naive + # isinstance(value, str) check falls through to validate_light_state() and silently + # drops KEEP/INVERT/INITIAL from the extracted docs schema. + assert _validate_restore_state_state(SCHEMA_EXTRACT) == ( + RESTORE_STATE_KEEP, + RESTORE_STATE_INVERT, + RESTORE_STATE_INITIAL, + "ON", + "OFF", + ) + + +def test_restore_state_schema_extract_returns_fields_schema() -> None: + # The `all`/`none` shorthands aren't representable here; extraction only walks + # the per-field mapping form, so it must resolve to the real fields schema + # rather than falling through to the untyped/unknown bucket. + assert RESTORE_STATE_SCHEMA(SCHEMA_EXTRACT) is _RESTORE_STATE_FIELDS_SCHEMA + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("state", Lambda("return true;")), + ("brightness", Lambda("return 1.0;")), + ("color_mode", Lambda("return light::ColorMode::ON_OFF;")), + ], +) +def test_restore_state_fields_reject_lambda(field: str, value: Lambda) -> None: + with pytest.raises(cv.Invalid): + RESTORE_STATE_SCHEMA({field: value}) + + +@pytest.mark.parametrize( + ("mode", "initial_state_config", "expected"), + [ + # No initial_state:, cold_boot_state False -- already matches + # LightStateRTCState's own `state{false}` default, nothing to emit. + ("ALWAYS_OFF", None, []), + ("ALWAYS_OFF", {}, []), + # No initial_state:, cold_boot_state True -- differs from the default. + ("ALWAYS_ON", None, [("state", "s.state = true;")]), + # initial_state: already set exactly the cold-boot value -- redundant. + ("ALWAYS_OFF", {CONF_STATE: False}, []), + ("RESTORE_AND_ON", {CONF_STATE: True}, []), + # initial_state: set a different value -- must be overridden. + ("ALWAYS_OFF", {CONF_STATE: True}, [("state", "s.state = false;")]), + ("RESTORE_AND_ON", {CONF_STATE: False}, [("state", "s.state = true;")]), + ], +) +def test_legacy_cold_boot_statements_skips_redundant_defaults( + mode: str, initial_state_config: dict | None, expected: list[StateStatement] +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert _legacy_cold_boot_statements(legacy, initial_state_config) == expected + + +def test_partition_no_overlap_produces_full_if_else() -> None: + body = _partition_state_statements( + [("brightness", "s.brightness = 1.0f;")], + [("state", "s.state = false;")], + True, + ) + assert body == [ + "if (restored) {", + "s.state = false;", + "} else {", + "s.brightness = 1.0f;", + "}", + ] + + +def test_partition_full_overlap_drops_branch_entirely() -> None: + # Both branches want exactly the same thing (e.g. RESTORE_AND_ON): no `if` at all. + body = _partition_state_statements( + [("state", "s.state = true;")], + [("state", "s.state = true;")], + True, + ) + assert body == ["s.state = true;"] + + +def test_partition_partial_overlap_hoists_shared_field() -> None: + # `state` matches in both branches and is hoisted out; `red`/`color_mode` differ + # per branch and stay inside a single-sided `if` each. + body = _partition_state_statements( + [ + ("red", "s.red = 0.5f;"), + ("state", "s.state = true;"), + ], + [ + ("state", "s.state = true;"), + ("color_mode", "s.color_mode = light::ColorMode::ON_OFF;"), + ], + True, + ) + assert body == [ + "s.state = true;", + "if (restored) {", + "s.color_mode = light::ColorMode::ON_OFF;", + "} else {", + "s.red = 0.5f;", + "}", + ] + + +def test_partition_duplicate_member_in_one_list_keeps_last_write() -> None: + # initial_statements can legitimately contain two writes to `state`: the user's own + # initial_state: value, followed by a legacy mode's cold-boot override. The earlier + # one is dead code (immediately overwritten) and must not survive partitioning. + body = _partition_state_statements( + [ + ("state", "s.state = true;"), + ("state", "s.state = false;"), + ], + [("state", "s.state = false;")], + True, + ) + assert body == ["s.state = false;"] + + +def test_partition_initial_only_without_save_skips_guard() -> None: + # save_enabled False means restored is unconditionally false at the call site + # (e.g. ALWAYS_OFF, restore_state: none, or neither key configured), so guarding + # the initial-only statements behind `if (!restored)` would only waste flash. + body = _partition_state_statements( + [("brightness", "s.brightness = 1.0f;")], + [], + False, + ) + assert body == ["s.brightness = 1.0f;"] + + +@pytest.mark.parametrize( + ("mode", "initial_state_config", "expected"), + [ + # No initial_state: at all -- nothing to override. + ("ALWAYS_OFF", None, False), + ("ALWAYS_ON", None, False), + # initial_state: set, but doesn't include state -- nothing to override. + ("ALWAYS_OFF", {}, False), + # initial_state: state already matches the mode's cold-boot value -- no-op. + ("ALWAYS_OFF", {CONF_STATE: False}, False), + ("RESTORE_AND_ON", {CONF_STATE: True}, False), + # initial_state: state set to something the mode's cold-boot force overrides. + ("ALWAYS_OFF", {CONF_STATE: True}, True), + ("RESTORE_AND_ON", {CONF_STATE: False}, True), + ], +) +def test_initial_state_overridden_by_legacy_mode( + mode: str, initial_state_config: dict | None, expected: bool +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert ( + _initial_state_overridden_by_legacy_mode(legacy, initial_state_config) + == expected + ) + + +def test_final_validate_warns_when_restore_mode_overrides_initial_state( + caplog: pytest.LogCaptureFixture, +) -> None: + # Regression test: this warning used to fire from setup_light_core_() during + # codegen; it now runs as part of FINAL_VALIDATE_SCHEMA instead, so it also + # surfaces on a plain `esphome config`, not just a full compile. + # + # FINAL_VALIDATE_SCHEMA for the `light:` domain runs once for the whole list + # of configured lights, not once per light -- pass a one-element list, matching + # the real call shape, not the single light's own config dict. + config = [ + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_OFF", + "initial_state": {"state": True}, + } + ) + ] + with caplog.at_level(logging.WARNING): + _final_validate(config) + assert "'initial_state: state' is ignored" in caplog.text + assert "restore_mode: ALWAYS_OFF" in caplog.text + + +def test_final_validate_does_not_warn_without_conflict( + caplog: pytest.LogCaptureFixture, +) -> None: + config = [ + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_OFF", + "initial_state": {"state": False}, + } + ) + ] + with caplog.at_level(logging.WARNING): + _final_validate(config) + assert caplog.text == "" + + +@pytest.mark.asyncio +async def test_restore_state_initial_state_field_copies_initial_state_value() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"}) + statements = await _restore_state_statements( + restore_state_config, {CONF_STATE: True} + ) + assert statements == [("state", "s.state = true;")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_other_field_copies_initial_state_value() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"brightness": 0.5} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("brightness", "s.brightness = 0.5f;")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_falls_back_to_struct_default() -> None: + # No initial_state: at all -- INITIAL resolves to a read of LightStateRTCState's + # own member-initializer default, straight from the struct. + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + statements = await _restore_state_statements(restore_state_config, None) + assert statements == [ + ("brightness", "s.brightness = LightStateRTCState{}.brightness;") + ] + + +@pytest.mark.asyncio +async def test_restore_state_initial_falls_back_when_initial_state_omits_field() -> ( + None +): + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"state": True} # doesn't set brightness + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [ + ("brightness", "s.brightness = LightStateRTCState{}.brightness;") + ] + + +@pytest.mark.asyncio +async def test_restore_state_initial_resolves_templated_initial_state() -> None: + # initial_state: gave `state` as a lambda -- INITIAL must call it (and cast its + # result), not just copy a literal value. + restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"}) + initial_state_config = {CONF_STATE: Lambda("return true;")} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("state", "s.state = static_cast(true);")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_resolves_templated_non_boolean_field() -> None: + # Same as above, but for a float-valued field -- the lambda's return type must + # be float, not bool. + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"brightness": Lambda("return 0.75;")} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("brightness", "s.brightness = static_cast(0.75);")] + + +def _mask(*modes: str) -> str: + casts = " | ".join(f"static_cast(light::ColorMode::{m})" for m in modes) + return f"static_cast({casts})" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("initial_state_config", "expected"), + [ + ({CONF_STATE: True}, None), + ({"red": 0.3}, _mask("RGB")), + ({"red": 0.3, "green": 0.0, "color_brightness": 0.5}, _mask("RGB")), + ({"brightness": 0.5}, _mask("BRIGHTNESS")), + ({"white": 0.5}, _mask("WHITE")), + ({"cold_white": 0.5, "warm_white": 0.5}, _mask("COLD_WARM_WHITE")), + ( + {"red": 0.3, "white": 0.5}, + _mask("RGB", "WHITE"), + ), + ({"red": Lambda("return 0.3;")}, _mask("RGB")), + ], +) +async def test_initial_state_infers_color_mode_from_colour_fields( + initial_state_config: dict, expected: str | None +) -> None: + statements = dict(await _initial_state_statements(initial_state_config)) + if expected is None: + assert "color_mode" not in statements + else: + assert statements["color_mode"] == f"s.color_mode = {expected};" + + +@pytest.mark.asyncio +async def test_initial_state_explicit_color_mode_is_not_inferred() -> None: + statements = dict( + await _initial_state_statements({"color_mode": ColorMode.RGB, "red": 0.3}) + ) + assert statements["color_mode"] == "s.color_mode = light::ColorMode::RGB;" + + +@pytest.mark.asyncio +async def test_restore_state_initial_color_mode_uses_inferred_mode() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"color_mode": "initial"}) + statements = await _restore_state_statements(restore_state_config, {"red": 0.3}) + assert statements == [("color_mode", f"s.color_mode = {_mask('RGB')};")] + + +def test_every_restore_state_field_offers_completion() -> None: + # Regression test: each field's validator must answer SCHEMA_EXTRACT (instead of + # raising), or the editor gets no completion for it. + for key, validator in _RESTORE_STATE_FIELDS_SCHEMA.schema.items(): + values = validator(SCHEMA_EXTRACT) + assert RESTORE_STATE_KEEP in values, key + assert RESTORE_STATE_INITIAL in values, key + + +def test_restore_state_color_mode_completion_includes_color_modes() -> None: + validator = _RESTORE_STATE_FIELDS_SCHEMA.schema[ + next(k for k in _RESTORE_STATE_FIELDS_SCHEMA.schema if k == "color_mode") + ] + values = validator(SCHEMA_EXTRACT) + assert {"KEEP", "INITIAL", "RGB", "COLD_WARM_WHITE"} <= set(values) diff --git a/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py b/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py new file mode 100644 index 00000000000..0842bc87076 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py @@ -0,0 +1,56 @@ +"""Regression test: action_to_code(): Confirms two +actions whose own processing each suspends mid-lambda, waiting for an ID, don't +corrupt each other's LambdaContext when interleaved. +""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +import esphome.codegen as cg +from esphome.components.lvgl.automation import action_to_code +from esphome.components.lvgl.lvcode import lv_add +from esphome.core import CORE, ID +from esphome.cpp_generator import RawExpression, TemplateArguments + + +@pytest.mark.asyncio +async def test_action_to_code_survives_interleaved_suspended_contexts( + setup_core, +) -> None: + later_id_a = ID("later_var_a", False, cg.int_) + later_id_b = ID("later_var_b", False, cg.int_) + action_type = ACTION_REGISTRY["lvgl.list.add"].type_id + + async def action_a(_widget) -> None: + value = await cg.get_variable(later_id_a) + lv_add(RawExpression(f"action_a_marker({value})")) + + async def action_b(_widget) -> None: + value = await cg.get_variable(later_id_b) + lv_add(RawExpression(f"action_b_marker({value})")) + + async def run_action_a() -> None: + action_id = ID("test_action_a", is_declaration=True, type=action_type) + await action_to_code([None], action_a, action_id, TemplateArguments(), []) + + async def run_action_b() -> None: + action_id = ID("test_action_b", is_declaration=True, type=action_type) + await action_to_code([None], action_b, action_id, TemplateArguments(), []) + + async def define_later_ids() -> None: + # Both actions are still suspended, mid-LambdaContext, when this runs - + # resolving both at once lets each resume while the other's context is + # still open, rather than one finishing before the other starts. + cg.new_variable(later_id_a, RawExpression("1")) + cg.new_variable(later_id_b, RawExpression("2")) + + CORE.add_job(run_action_a) + CORE.add_job(run_action_b) + CORE.add_job(define_later_ids) + CORE.flush_tasks() + + text = "\n".join(str(s) for s in CORE.main_statements) + assert "action_a_marker(later_var_a)" in text + assert "action_b_marker(later_var_b)" in text diff --git a/tests/unit_tests/components/lvgl/test_get_part_state_selector.py b/tests/unit_tests/components/lvgl/test_get_part_state_selector.py new file mode 100644 index 00000000000..e7d623b7c6b --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_get_part_state_selector.py @@ -0,0 +1,33 @@ +"""Tests for get_part_state_selector()'s three branches.""" + +from __future__ import annotations + +from esphome.components.lvgl.defines import get_part_state_selector + + +def test_default_state_returns_bare_part() -> None: + assert str(get_part_state_selector("main", "default")) == "LV_PART_MAIN" + assert str(get_part_state_selector("knob", "default")) == "LV_PART_KNOB" + + +def test_main_part_with_non_default_state_returns_bare_state() -> None: + assert str(get_part_state_selector("main", "pressed")) == "LV_STATE_PRESSED" + + +def test_non_main_part_with_non_default_state_combines_both() -> None: + assert str(get_part_state_selector("knob", "pressed")) == ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ) + + +def test_accepts_already_prefixed_part_and_state() -> None: + assert ( + str(get_part_state_selector("LV_PART_MAIN", "LV_STATE_DEFAULT")) + == "LV_PART_MAIN" + ) + assert ( + str(get_part_state_selector("LV_PART_KNOB", "LV_STATE_PRESSED")) + == "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ) diff --git a/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py b/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py new file mode 100644 index 00000000000..6caf83fe330 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py @@ -0,0 +1,129 @@ +"""Regression test: an lvgl.list.add action must fire a list's on_add trigger +even if it reaches _fire_on_add() before finish_list_triggers() has built that +list's Trigger Pvariable. + +ListType.on_create() only records on_add/on_remove configs (via +_declare_list_triggers()) - finish_list_triggers() is what actually builds the +Trigger Pvariables from them. An lvgl.list.add action for a list can be +scheduled as part of a different component's own to_code() coroutine, entirely +independent of lvgl's own, so it can reach _fire_on_add() before +finish_list_triggers() has run for that list. _fire_on_add()/_fire_on_remove() +resolve each trigger via cg.get_variable(), which blocks until +finish_list_triggers() builds it - regardless of which of the two jobs the +scheduler happens to run first. + +This is reproduced deterministically here (no reliance on incidental component +priority/scheduling) by scheduling the action's job before finish_list_triggers() +on ESPHome's own coroutine scheduler: without cg.get_variable()'s wait, the +action job would run to completion first and observe the trigger as not yet +built. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.lvcode import LvContext +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.widgets import Widget, widget_to_code +from esphome.components.lvgl.widgets.lv_list import ( + CONF_ON_ADD, + _get_list_triggers, + finish_list_triggers, + list_spec, +) +from esphome.const import CONF_AUTOMATION_ID, CONF_THEN, CONF_TRIGGER_ID, CONF_TYPE_ID +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArguments +from esphome.yaml_util import make_data_base + + +def _statements() -> list[str]: + return [str(s) for s in CORE.main_statements] + + +@pytest.mark.asyncio +async def test_list_add_action_running_before_finish_list_triggers_still_fires_on_add( + setup_core, +) -> None: + config = container_schema(list_spec)( + { + "id": "test_list", + CONF_ON_ADD: [{"lambda": make_data_base("return;")}], + } + ) + # Auto-generated IDs (trigger/automation/action) are normally resolved to + # unique names by esphome's full config pass before code generation; do + # that by hand here since this test only exercises the widget/trigger + # codegen slice in isolation. + automation_conf = config[CONF_ON_ADD][0] + automation_conf[CONF_TRIGGER_ID].resolve([]) + automation_conf[CONF_AUTOMATION_ID].resolve([]) + automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([]) + + parent = MockObj("parent_obj") + async with LvContext(): + await widget_to_code(config, list_spec, parent) + + # Schedule the lvgl.list.add action's job before finish_list_triggers()'s - + # mirroring an action that lives in a different component's automation than + # lvgl's own to_code(), which can reach this action before lvgl gets to build + # this list's on_add/on_remove triggers. + entry = ACTION_REGISTRY["lvgl.list.add"] + add_config = entry.schema({"id": "test_list", "label": {"text": "row"}}) + action_id = ID("test_list_add_action", is_declaration=True, type=entry.type_id) + + async def run_add_action() -> None: + async with LvContext(): + await entry.coroutine_fun(add_config, action_id, TemplateArguments(), []) + + CORE.add_job(run_add_action) + CORE.add_job(finish_list_triggers) + CORE.flush_tasks() + + statements = _statements() + assert any("->trigger(" in s for s in statements), ( + "on_add did not fire: the lvgl.list.add action ran before " + "finish_list_triggers() built the list's on_add trigger, and " + "_fire_on_add() didn't wait for it" + ) + + +@pytest.mark.asyncio +async def test_on_add_recorded_before_widget_registered(setup_core) -> None: + """Widget.create() is what makes a list visible to get_widgets(), so an + action interleaved with its creation could resolve get_widgets() and reach + _fire_on_add() right after Widget.create() runs. Its on_add config must + already be recorded by then - ListType.on_create() (called before + Widget.create()) is what guarantees that, not ListType.to_code() (called + after). + """ + config = container_schema(list_spec)( + { + "id": "test_list", + CONF_ON_ADD: [{"lambda": make_data_base("return;")}], + } + ) + automation_conf = config[CONF_ON_ADD][0] + automation_conf[CONF_TRIGGER_ID].resolve([]) + automation_conf[CONF_AUTOMATION_ID].resolve([]) + automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([]) + + seen_on_add_counts = [] + real_create = Widget.create + + def spy_create(name, var, wtype, config=None): + seen_on_add_counts.append(len(_get_list_triggers(name).on_add)) + return real_create(name, var, wtype, config) + + parent = MockObj("parent_obj") + with patch.object(Widget, "create", side_effect=spy_create): + async with LvContext(): + await widget_to_code(config, list_spec, parent) + + assert seen_on_add_counts == [1], ( + "on_add wasn't recorded yet when Widget.create() registered the list" + ) diff --git a/tests/unit_tests/components/lvgl/test_table_codegen.py b/tests/unit_tests/components/lvgl/test_table_codegen.py index 390f67dffcf..12ebd3a12f1 100644 --- a/tests/unit_tests/components/lvgl/test_table_codegen.py +++ b/tests/unit_tests/components/lvgl/test_table_codegen.py @@ -5,7 +5,6 @@ from __future__ import annotations import pytest from esphome.automation import ACTION_REGISTRY -from esphome.components.lvgl.defines import set_widgets_completed from esphome.components.lvgl.lvcode import LvContext from esphome.components.lvgl.schemas import container_schema from esphome.components.lvgl.trigger import generate_triggers @@ -151,7 +150,6 @@ async def test_selected_cell_omitted_entirely_when_not_configured( @pytest.mark.asyncio async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None: await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]}) - set_widgets_completed(True) # Only inspect statements emitted by the action below, not by creation. before = len(_statements()) @@ -194,7 +192,6 @@ async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> parent = MockObj("parent_obj") async with LvContext(): await widget_to_code(config, table_spec, parent) - set_widgets_completed(True) await generate_triggers() statements = _statements() diff --git a/tests/unit_tests/components/lvgl/test_theme_update_ordering.py b/tests/unit_tests/components/lvgl/test_theme_update_ordering.py new file mode 100644 index 00000000000..62b8a4eaedf --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_theme_update_ordering.py @@ -0,0 +1,44 @@ +"""Regression test: an lvgl.theme.update action must still apply its style +change even if it runs before theme_to_code() has built that style. + +theme_update_to_code() reads get_theme_widget_map() synchronously and raises +cv.Invalid if the requested style isn't there yet - relying on theme_to_code() +(which materialises a style for every requested widget/part/state combo) to +have always already run. That's guaranteed when the action lives inside the +lvgl: block's own automations (same to_code() job, sequential), but not when +it's scheduled as part of a different component's own to_code() job - e.g. +tests/components/lvgl/lvgl-package.yaml's `esphome: on_boot:` case, which this +test reproduces at the scheduler level. +""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.schemas import theme_update_schema +from esphome.components.lvgl.styles import theme_to_code +from esphome.core import CORE, ID +from esphome.cpp_generator import TemplateArguments + + +@pytest.mark.asyncio +async def test_theme_update_before_theme_to_code_still_applies(setup_core) -> None: + add_config = theme_update_schema({"obj": {"border_width": 2}}) + + entry = ACTION_REGISTRY["lvgl.theme.update"] + action_id = ID("test_theme_update_action", is_declaration=True, type=entry.type_id) + + async def run_update_action() -> None: + await entry.coroutine_fun(add_config, action_id, TemplateArguments(), []) + + # Scheduled before theme_to_code()'s job - mirrors the action being reached + # from a different component's own to_code() job than lvgl's. + CORE.add_job(run_update_action) + CORE.add_job(theme_to_code, {}) + CORE.flush_tasks() + + statements = [str(s) for s in CORE.main_statements] + assert any("style_set_border_width" in s for s in statements), ( + "theme.update's border_width change was never applied" + ) diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py new file mode 100644 index 00000000000..5af55586c33 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -0,0 +1,147 @@ +"""nrf52 sdk-nrf precompiled header: the CMake block and the ccache checksum.""" + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from esphome.components import nrf52 +from esphome.components.zephyr.const import KEY_BOARD +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain +from esphome.core import CORE, EsphomeError + +SUM = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" + + +def _generate_cmake(tmp_path: Path, pch_on: bool = True) -> str: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + with ( + patch( + "esphome.components.zephyr.library.generate_zephyr_modules", + return_value=[], + ), + patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]), + patch.object(nrf52, "get_project_link_flags", return_value=[]), + ): + nrf52._generate_cmake_lists(pch_on) + return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() + + +def test_cmake_lists_precompile_the_core_headers(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path) + assert "target_precompile_headers(app PRIVATE ${esphome_pch_headers})" in text + assert "/../src/esphome/core/pch_prefix.h" in text + # The headers Zephyr forces leave the C++ compiles of the app only + assert ( + "$<$," + "$,app>>>:${option}>" + ) in text + assert r'REPLACE "(.+)" "$<$:\\1>"' in text + # A Zephyr that spells them another way must not go unnoticed + assert "if(NOT esphome_pch_headers)\n message(FATAL_ERROR" in text + + +def test_cmake_lists_pch_block_disabled(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path, pch_on=False) + assert "precompile" not in text + assert "zephyr_interface" not in text + + +@pytest.mark.parametrize(("version", "on"), [((12, 2, 0), False), ((14, 4, 0), True)]) +def test_the_zephyr_compiler_decides_on_windows( + windows_gcc_rule: None, version: tuple[int, ...], on: bool +) -> None: + from esphome.build_helpers import pch + + # platformdirs would pick its Windows backend from the patched sys.platform + with ( + patch.object(nrf52, "toolchain_tool", lambda name: Path(f"/sdk/{name}.exe")), + patch.object(pch, "gcc_version", return_value=version) as asked, + ): + assert nrf52._pch_usable() is on + assert asked.call_args.args[0] == (Path("/sdk/g++.exe"),) + + +def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path: + """Write the checksum for a build dir whose app image sits in ``app``.""" + CORE.build_path = tmp_path + header = tmp_path / "src" / "esphome" / "core" / "pch_prefix.h" + header.parent.mkdir(parents=True, exist_ok=True) + header.write_text("#define M 1\n") + source_dir = tmp_path / "zephyr" + source_dir.mkdir(exist_ok=True) + (source_dir / "prj.conf").write_text(conf) + (source_dir / "CMakeLists.txt").write_text("not part of the checksum\n") + build_dir = tmp_path / ".pioenvs" / "livingroom" + (build_dir / app).mkdir(parents=True, exist_ok=True) + (build_dir / app / "CMakeCache.txt").write_text("") + with ( + patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}), + patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + ): + nrf52._write_pch_checksum(build_dir, source_dir) + return build_dir / app / SUM + + +@pytest.mark.parametrize("app", ["zephyr", "."]) +def test_pch_checksum_is_written_next_to_the_gch(tmp_path: Path, app: str) -> None: + """Sysbuild nests the app image; without it the build dir is the app.""" + sum_path = _write_checksum(tmp_path, app) + assert len(sum_path.read_text().strip()) == 64 + + +def test_pch_checksum_tracks_the_zephyr_configuration(tmp_path: Path) -> None: + first = _write_checksum(tmp_path, "zephyr").read_text() + assert _write_checksum(tmp_path, "zephyr", "CONFIG_X=n\n").read_text() != first + + +def test_pch_checksum_waits_for_the_first_configure(tmp_path: Path) -> None: + CORE.build_path = tmp_path + build_dir = tmp_path / ".pioenvs" / "livingroom" + nrf52._write_pch_checksum(build_dir, tmp_path / "zephyr") + assert not build_dir.exists() + + +@pytest.fixture +def run_cmd(tmp_path: Path) -> Mock: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + CORE.toolchain = Toolchain.SDK_NRF + with ( + patch.object(nrf52, "check_and_install"), + patch.object(nrf52, "_generate_cmake_lists", return_value=False), + patch.object( + nrf52, + "get_build_paths", + return_value={"python_executable": "python3", "framework_path": tmp_path}, + ), + patch.object(nrf52, "get_build_env", return_value={}), + patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + patch.object(nrf52, "run_command_ok", return_value=False) as run, + ): + yield run + + +def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: + # clear=True also drops ambient CCACHE_* overrides; the header is on + # explicitly since Windows hosts start with it off + with ( + patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}, clear=True), + pytest.raises(EsphomeError, match="nRF52 native build failed"), + ): + nrf52.run_compile(None, {}) + env = run_cmd.call_args.kwargs["env"] + assert env["CCACHE_PCH_EXTSUM"] == "true" + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + + +def test_disabled_leaves_the_west_environment_alone( + run_cmd: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + nrf52.run_compile(None, {}) + assert "CCACHE_PCH_EXTSUM" not in run_cmd.call_args.kwargs["env"] diff --git a/tests/unit_tests/components/test_espnow.py b/tests/unit_tests/components/test_espnow.py new file mode 100644 index 00000000000..21305c2b332 --- /dev/null +++ b/tests/unit_tests/components/test_espnow.py @@ -0,0 +1,48 @@ +"""Tests for the espnow component's final validation.""" + +import pytest + +from esphome.components.esp32.const import ( + VARIANT_ESP32C3, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +) +from esphome.components.espnow import _validate_variant +import esphome.config_validation as cv +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _run( + monkeypatch, variant: str, full_config: dict, config: ConfigType +) -> ConfigType: + monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant) + token = fv.full_config.set(full_config) + try: + return _validate_variant(config) + finally: + fv.full_config.reset(token) + + +def test_variant_with_native_wifi_passes(monkeypatch) -> None: + """A variant with a native Wi-Fi PHY needs no shim; config passes through.""" + config = {"id": "espnow"} + assert _run(monkeypatch, VARIANT_ESP32C3, {}, config) is config + + +def test_radioless_non_p4_variant_rejected(monkeypatch) -> None: + """Radio-less variants without any ESP-NOW path are rejected outright.""" + with pytest.raises(cv.Invalid, match="not supported"): + _run(monkeypatch, VARIANT_ESP32H2, {}, {}) + + +def test_p4_without_esp32_hosted_rejected(monkeypatch) -> None: + """The P4 needs the esp32_hosted shim to supply the esp_now_* symbols.""" + with pytest.raises(cv.Invalid, match="esp32_hosted"): + _run(monkeypatch, VARIANT_ESP32P4, {}, {}) + + +def test_p4_with_esp32_hosted_passes(monkeypatch) -> None: + """The P4 with esp32_hosted present validates; config passes through.""" + config = {"id": "espnow"} + assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config diff --git a/tests/unit_tests/components/udp/__init__.py b/tests/unit_tests/components/udp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/udp/test_init.py b/tests/unit_tests/components/udp/test_init.py new file mode 100644 index 00000000000..5afc92e9c67 --- /dev/null +++ b/tests/unit_tests/components/udp/test_init.py @@ -0,0 +1,37 @@ +"""Tests for the udp component configuration schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components import udp +from esphome.components.packet_transport import ( + CONF_BINARY_SENSORS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_PROVIDERS, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, +) +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + "option", + [ + CONF_PROVIDERS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, + CONF_BINARY_SENSORS, + ], +) +def test_relocated_option_rejected(option: str) -> None: + """Options that moved to packet_transport raise a pointing error.""" + with pytest.raises(cv.Invalid) as exc_info: + udp.CONFIG_SCHEMA({option: True}) + assert ( + f"The '{option}' option should now be configured in the 'packet_transport' component" + in str(exc_info.value) + ) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 9de8f715efc..ff5564e8150 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -9,7 +9,7 @@ not be part of a unit test suite. """ -from collections.abc import Generator +from collections.abc import Callable, Generator import os from pathlib import Path import sys @@ -137,3 +137,56 @@ def mock_get_component() -> Generator[Mock, None, None]: """Mock get_component for config module.""" with patch("esphome.config.get_component") as mock: yield mock + + +@pytest.fixture +def held_lock() -> Callable[..., Callable[..., None]]: + """Factory for a ``FileLock.acquire`` fake held by another downloader. + + Each poll writes the next chunk to ``part`` (or runs it, for a callable) + and raises ``Timeout``; when the chunks run out the part is removed, + ``land()`` runs, and the acquire succeeds (also for any later job, so + ``land`` must be idempotent). + """ + from filelock import Timeout + + def make( + part: Path, + chunks: list[bytes | Callable[[], None]], + land: Callable[[], None], + ) -> Callable[..., None]: + polls = iter(chunks) + + def acquire(*args, **kwargs) -> None: + try: + chunk = next(polls) + except StopIteration: + part.unlink(missing_ok=True) + land() + return + if callable(chunk): + chunk() + else: + part.parent.mkdir(parents=True, exist_ok=True) + part.write_bytes(chunk) + raise Timeout("held") + + return acquire + + return make + + +@pytest.fixture(autouse=True) +def _default_pch_knobs(monkeypatch: pytest.MonkeyPatch) -> None: + """The knob changes what the generators emit: no ambient value, and on, + so the Windows runner asks no compiler.""" + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + + +@pytest.fixture +def windows_gcc_rule(monkeypatch: pytest.MonkeyPatch) -> None: + """Windows host, knob unset: the compiler version decides.""" + from esphome.build_helpers import pch + + monkeypatch.delenv("ESPHOME_PCH_ENABLE") + monkeypatch.setattr(pch.sys, "platform", "win32") diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 8ab3ad5d153..189c024b3a7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core +import esphome.codegen as cg from esphome.components.safe_mode import to_code as safe_mode_to_code from esphome.const import ( CONF_AREA, @@ -23,7 +24,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE, config +from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config from esphome.core.config import ( Area, make_app_name_cpp, @@ -175,6 +176,27 @@ async def test_core_area_recorded_at_config_load( assert CORE.area == expected_area +@pytest.mark.asyncio +async def test_app_is_default_initialized( + yaml_file: Callable[[str], Path], +) -> None: + """App is constructed with `new (&App) Application`, no parentheses. + + `Application()` would value-initialize and memset the whole object into + storage that is already zero.""" + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + raw_expressions = [c.args[0] for c in mock_cg.RawExpression.call_args_list] + assert "new (&App) Application" in raw_expressions + assert "new (&App) Application()" not in raw_expressions + + def test_config_load_without_area_clears_stale_core_area( yaml_file: Callable[[str], Path], ) -> None: @@ -434,6 +456,35 @@ async def test_add_looping_components_with_entries() -> None: assert "(1 * HasLoopOverride::value)" in text +@pytest.mark.asyncio +async def test_add_controller_registry_dispatch_without_controllers() -> None: + """Nothing is emitted when no controller registered.""" + CORE.data.pop(KEY_CONTROLLER_REGISTRY_CONTROLLERS, None) + + await config._add_controller_registry_dispatch() + + assert "USE_CONTROLLER_REGISTRY" not in {d.name for d in CORE.defines} + assert not [s for s in CORE.global_statements if "controller" in str(s)] + + +@pytest.mark.asyncio +async def test_add_controller_registry_dispatch_with_controllers() -> None: + """Registered controllers become one tuple plus the dispatch include.""" + CORE.register_controller(cg.MockObj("api_apiserver_id")) + CORE.register_controller(cg.MockObj("web_server_webserver_id")) + + await config._add_controller_registry_dispatch() + + assert "USE_CONTROLLER_REGISTRY" in {d.name for d in CORE.defines} + statements = [str(s) for s in CORE.global_statements] + assert "#include " in statements + assert ( + "static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; }" + in statements + ) + assert '#include "esphome/core/controller_dispatch.h"' in statements + + def test_valid_include_with_angle_brackets() -> None: """Test valid_include accepts angle bracket includes.""" assert valid_include("") == "" @@ -1429,12 +1480,3 @@ async def test_add_platformio_options_native_arduino( assert "board_build.ldscript is ignored" in caplog.text assert "'arduino' toolchain" in caplog.text assert "upload_speed" not in caplog.text - - -def test_esp8266_rejects_unsupported_cli_toolchain() -> None: - """Until the native backend lands, ESP8266 serves only PlatformIO.""" - from esphome.components.esp8266 import CONFIG_SCHEMA - - CORE.toolchain = Toolchain.ARDUINO - with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): - CONFIG_SCHEMA({"board": "nodemcuv2"}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 53035ad7136..cd6b3315178 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1236,3 +1236,61 @@ async def test_finalize_comment_sanitization( # Newline must be replaced to prevent breaking out of comment assert "\n" not in comment_line assert "INJECTED_CODE" in comment_line # still visible but safe in comment + + +@pytest.mark.asyncio +async def test_sub_entities_creates_and_sets( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] +) -> None: + """A configured key creates the entity, sets the parent before the setter and returns it.""" + calls: list[tuple[Any, ...]] = [] + created = MockObj("child") + + async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: + calls.append((conf, args, kwargs)) + return created + + async def fake_register_parented(var: MockObj, parent: MockObj) -> None: + setup_test_environment.append(f"parent {var} {parent}") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub) + + assert await sub("child", hub.set_child, 1, step=2) is created + assert calls == [({}, (1,), {"step": 2})] + assert setup_test_environment == ["parent child hub", "hub->set_child(child)"] + + +@pytest.mark.asyncio +async def test_sub_entities_missing_key(setup_test_environment: list[str]) -> None: + """A missing key creates nothing and emits no code.""" + + async def new_entity(conf: dict[str, Any]) -> MockObj: + raise AssertionError("must not be called") + + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {}) + + assert await sub("child", hub.set_child) is None + assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_without_parent( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] +) -> None: + """Without a bound parent, register_parented is not called.""" + + async def fail_register_parented(var: MockObj, parent: MockObj) -> None: + raise AssertionError("must not be called") + + async def new_entity(conf: dict[str, Any]) -> MockObj: + return MockObj("child") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {CONF_ID: "child"}}) + + assert await sub("child", hub.set_child) is not None + assert setup_test_environment == ["hub->set_child(child)"] diff --git a/tests/unit_tests/fixtures/espidf/closing_probe.py b/tests/unit_tests/fixtures/espidf/closing_probe.py deleted file mode 100644 index a77d5c8f281..00000000000 --- a/tests/unit_tests/fixtures/espidf/closing_probe.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Leave a partial line behind and then close the stream under the runner. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining -cannot work here; the point is that the failure is reported rather than -raised out of the runner's cleanup, where it would bury the exit code. -""" - -import sys - -sys.stdout.write("partial before close") -sys.stdout.close() diff --git a/tests/unit_tests/fixtures/espidf/crashing_probe.py b/tests/unit_tests/fixtures/espidf/crashing_probe.py deleted file mode 100644 index bf434cc24e1..00000000000 --- a/tests/unit_tests/fixtures/espidf/crashing_probe.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Die part way through a line, the way a build that blows up does. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The -message has no trailing newline, so the runner's shim is holding it when -the process exits; nothing else will ever come to release it. -""" - -import sys - -sys.stdout.write("FATAL: ld returned 1 exit status") -sys.exit(2) diff --git a/tests/unit_tests/fixtures/espidf/filtering_probe.py b/tests/unit_tests/fixtures/espidf/filtering_probe.py deleted file mode 100644 index 04c2b2ed8cc..00000000000 --- a/tests/unit_tests/fixtures/espidf/filtering_probe.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Write a mix of noisy and useful build lines, without flushing. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The -runner's shim owns both the filtering and the flushing, so this script -only writes. -""" - -import sys - -sys.stdout.write("Project build complete.\n") -sys.stdout.write("Compiling main.cpp\n") -sys.stdout.write("-- Component paths: /a /b /c\n") -sys.stdout.write("[2/9] Building C object\n") -# No terminator, so the shim has to hold this one back. -sys.stdout.write("still going") diff --git a/tests/unit_tests/fixtures/espidf/formfeed_probe.py b/tests/unit_tests/fixtures/espidf/formfeed_probe.py deleted file mode 100644 index 727cda25ce7..00000000000 --- a/tests/unit_tests/fixtures/espidf/formfeed_probe.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Write a form feed part way through the output. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form -feed is not a line terminator here, so everything written must still come -out, including the complete lines that follow it. -""" - -import sys - -sys.stdout.write("Compiling main.cpp\n") -sys.stdout.write("page one\x0cpage two\n") -sys.stdout.write("[2/9] Building C object\n") diff --git a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py deleted file mode 100644 index 9c81f8eb7b3..00000000000 --- a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py +++ /dev/null @@ -1,10 +0,0 @@ -"""End on an unterminated line that the filter is supposed to drop. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to -check that releasing a held-back line still applies the filter. -""" - -import sys - -sys.stdout.write("Compiling main.cpp\n") -sys.stdout.write("Project build complete.") diff --git a/tests/unit_tests/fixtures/espidf/streaming_probe.py b/tests/unit_tests/fixtures/espidf/streaming_probe.py deleted file mode 100644 index c05741e3117..00000000000 --- a/tests/unit_tests/fixtures/espidf/streaming_probe.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Print one line, then stay alive so the caller can prove it streamed. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The -runner wraps stdout in its filtering shim, so this script deliberately -does not flush: the shim has to do it. The long sleep keeps the process -running, so anything the caller reads must have arrived while the build -was still going rather than at exit. -""" - -import sys -import time - -sys.stdout.write("Compiling main.cpp\n") -time.sleep(60) diff --git a/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py new file mode 100644 index 00000000000..ee9751de80d --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py @@ -0,0 +1,65 @@ +"""Run the native-toolchain serial-upload path and report heavy modules. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +``upload_using_esptool`` dispatches native toolchains through a +toolchain-keyed table; if someone routes it back through the platform +component packages (esp32 or esp8266), this reports the leak. +""" + +import os +from pathlib import Path +import sys +import tempfile +from unittest.mock import patch + +from _leak_report import print_leaked_modules + +from esphome.__main__ import upload_using_esptool +from esphome.const import ( + CONF_ESPHOME, + KEY_CORE, + KEY_ESP32, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + Toolchain, +) +from esphome.core import CORE + +# An ambient ESPHOME_USE_SUBPROCESS would route past the patched +# run_external_command into run_external_process and confuse the checks. +os.environ.pop("ESPHOME_USE_SUBPROCESS", None) + +config = {CONF_ESPHOME: {"platformio_options": {}}} + +with tempfile.TemporaryDirectory() as build_dir: + CORE.name = "leaktest" + CORE.build_path = build_dir + + for platform, toolchain, backend in ( + ("esp8266", Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), + ("esp32", Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + ): + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + if platform == "esp32": + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + CORE.toolchain = toolchain + + import importlib + + image = importlib.import_module(backend).get_factory_firmware_path() + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(b"\x00") + + with patch("esphome.__main__.run_external_command", return_value=0) as mock_run: + rc = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + # Fail loudly if the upload path stopped doing its work; otherwise + # an empty leak list could just mean nothing ran. + if rc != 0: + sys.exit(f"upload_using_esptool({platform}) returned {rc}") + cmd = list(mock_run.call_args[0][1:]) + if str(image) not in [str(Path(c)) for c in cmd]: + sys.exit(f"native factory image did not reach esptool: {cmd}") + +print_leaked_modules() diff --git a/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml b/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml deleted file mode 100644 index a49ae706a40..00000000000 --- a/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -substitutions: - name: original - -wifi: !include - file: includes/broken_included.yaml.txt - vars: - name: my_custom_ssid - -esphome: - # should be substituted as 'original', - # not overwritten by vars in the !include above - name: ${name} - name_add_mac_suffix: true - libraries: !include {file: includes/list.yaml, vars: {var1: Wire}} - -esp8266: - board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}} diff --git a/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt b/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt deleted file mode 100644 index 6e53395c865..00000000000 --- a/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt +++ /dev/null @@ -1,5 +0,0 @@ ---- -# yamllint disable-line - ssid: ${name} -# yamllint disable-line - fdf: error diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index 1ca28c4f029..71fb632d6df 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -257,6 +257,42 @@ def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None: assert "Invalid cache format: also_invalid" in caplog.text +@pytest.mark.parametrize( + "args,expected", + [ + (["device.local="], {}), + (["device.local= , , "], {}), + (["=192.168.1.10"], {}), + (["...=192.168.1.10"], {}), + ( + [" device.local. = , 192.168.1.10, , fe80::1, "], + {"device.local": ["192.168.1.10", "fe80::1"]}, + ), + ], +) +def test_from_cli_args_discards_empty_addresses_and_hostnames( + args: list[str], expected: dict[str, list[str]], caplog: LogCaptureFixture +) -> None: + """Only usable cache entries may prevent normal hostname resolution.""" + cache = AddressCache.from_cli_args(args, args) + + assert cache.mdns_cache == expected + assert cache.dns_cache == expected + assert cache.has_cache() == bool(expected) + if not expected: + assert cache.get_addresses("device.local") is None + assert "Invalid cache entry" in caplog.text + + +def test_invalid_cache_entry_does_not_replace_valid_entry() -> None: + """A later empty entry must not remove an earlier usable address.""" + args = ["device.local=192.168.1.10", "device.local=", "server.com=10.0.0.1"] + cache = AddressCache.from_cli_args(args, args) + + assert cache.get_mdns_addresses("DEVICE.LOCAL.") == ["192.168.1.10"] + assert cache.get_dns_addresses("server.com") == ["10.0.0.1"] + + def test_from_cli_args_ipv6() -> None: """Test handling of IPv6 addresses.""" mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"] diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 9f415344aea..5ffcea0114a 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from esphome.arduino8266 import framework +from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION import esphome.config_validation as cv from esphome.core import CORE, EsphomeError @@ -18,32 +19,6 @@ def _build_path(tmp_path: Path) -> None: CORE.build_path = tmp_path -def test_framework_package_version() -> None: - assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" - assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" - # A future major bump needs its own encoding, not a doomed registry lookup - with pytest.raises(EsphomeError, match="not supported yet"): - framework.framework_package_version(cv.Version(4, 0, 0)) - # Cores before 3.x cannot build ESPHome (C++20) and are rejected - with pytest.raises(EsphomeError, match="requires core 3"): - framework.framework_package_version(cv.Version(2, 7, 4)) - - -def test_format_framework_arduino_version_pins_all_series() -> None: - """The esp8266 component's PIO source formatter across every encoding - era, including the 4.x rejection it now shares with the installer.""" - from esphome.components.esp8266 import _format_framework_arduino_version as fmt - - assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0" - # Pre-3 cores are rejected with the version line anchored - with pytest.raises(cv.Invalid, match="requires core 3"): - fmt(cv.Version(2, 7, 4)) - # Anchored to the framework version line, not a bare EsphomeError - with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: - fmt(cv.Version(4, 0, 0)) - assert excinfo.value.path == ["version"] - - def test_tools_path_default_and_prefix(tmp_path: Path) -> None: with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}): assert framework.get_arduino8266_tools_path() == tmp_path.resolve() @@ -54,54 +29,155 @@ def test_tools_path_default_and_prefix(tmp_path: Path) -> None: assert path != Path.cwd() +def _recommended() -> framework.FrameworkRelease: + return framework.FRAMEWORK_RELEASES[RECOMMENDED_ARDUINO_FRAMEWORK_VERSION] + + +def test_framework_releases_are_pinned() -> None: + """A release must not be pinned without its checksum, and the recommended + core must have one.""" + assert RECOMMENDED_ARDUINO_FRAMEWORK_VERSION in framework.FRAMEWORK_RELEASES + for version, release in framework.FRAMEWORK_RELEASES.items(): + assert release.tag.startswith(f"{version}-esphome.") + assert len(release.sha256) == 64 + assert release.size > 0 + + +def test_framework_download() -> None: + download = framework.FrameworkRelease("1.2.3-esphome.4", "a" * 64, 5).download() + releases = "https://github.com/esphome-libs/arduino-esp8266/releases/" + archive = "arduino-esp8266-1.2.3-esphome.4.tar.gz" + assert download == (f"{releases}download/1.2.3-esphome.4/{archive}", "a" * 64, 5) + + +def test_toolchain_builds_are_pinned() -> None: + """A release must not be pinned without its checksums.""" + for sha256, size in framework.TOOLCHAIN_BUILDS.values(): + assert len(sha256) == 64 + assert size > 0 + + +def test_toolchain_download() -> None: + sha256, size = framework.TOOLCHAIN_BUILDS["darwin_arm64"] + with ( + patch.object(framework, "TOOLCHAIN_VERSION", "1.2.3"), + patch.object(framework, "get_systype", return_value="darwin_arm64"), + ): + download = framework.toolchain_download() + releases = "https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/" + archive = "toolchain-xtensa-lx106-elf-1.2.3-darwin_arm64.tar.gz" + assert download == (f"{releases}download/1.2.3/{archive}", sha256, size) + + +def test_toolchain_download_unsupported_system() -> None: + with ( + patch.object(framework, "get_systype", return_value="linux_armv7l"), + pytest.raises( + EsphomeError, match=r"linux_armv7l.*darwin_arm64.*toolchain: platformio" + ), + ): + framework.toolchain_download() + + +def _fake_framework(tmp_path: Path) -> None: + """The layout install_package expects of an installed framework.""" + for sub in ("cores/esp8266", "tools/sdk", "libraries"): + (tmp_path / "frameworks" / _recommended().tag / sub).mkdir(parents=True) + (tmp_path / "frameworks" / _recommended().tag / ".esphome_extracted").touch() + + +def test_check_and_install_mirror_skips_pinned_toolchain(tmp_path: Path) -> None: + """With a mirror override an unsupported host can bring its own toolchain.""" + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", ["http://f"]), + patch.object(framework, "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", ["http://m"]), + patch.object(framework, "install_packages") as mock_install, + patch.object(framework, "prefetch_packages") as mock_prefetch, + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + ): + framework.check_and_install(cv.Version(3, 1, 2)) + assert mock_prefetch.call_args.args[2] == {} + assert mock_install.call_args.args[2] == {} + + +def test_check_and_install_installed_toolchain_on_unsupported_host( + tmp_path: Path, +) -> None: + """The host is only checked when the toolchain must be downloaded.""" + _fake_framework(tmp_path) + toolchain = tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION + for sub in ("bin", "xtensa-lx106-elf"): + (toolchain / sub).mkdir(parents=True) + (toolchain / ".esphome_extracted").touch() + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "get_systype", return_value="linux_armv7l"), + patch.object(framework, "prefetch_packages"), + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + ): + paths = framework.check_and_install(cv.Version(3, 1, 2)) + assert paths.toolchain == toolchain + + +def test_check_and_install_unsupported_host_without_toolchain_raises( + tmp_path: Path, +) -> None: + """Without an installed toolchain the download is needed, and the host + error names the ways out.""" + _fake_framework(tmp_path) + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "get_systype", return_value="linux_armv7l"), + patch.object(framework, "prefetch_packages"), + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + pytest.raises(EsphomeError, match=r"linux_armv7l.*toolchain: platformio"), + ): + framework.check_and_install(cv.Version(3, 1, 2)) + + def test_check_and_install_returns_paths(tmp_path: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), - patch.object(framework, "install_package") as mock_install, + patch.object(framework, "install_packages") as mock_install, patch.object(framework, "prefetch_packages") as mock_prefetch, patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), ): paths = framework.check_and_install(cv.Version(3, 1, 2)) - assert paths.framework == tmp_path / "frameworks" / "3.30102.0" + assert paths.framework == tmp_path / "frameworks" / _recommended().tag assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION assert paths.ninja == tmp_path / "ninja" - assert mock_install.call_count == 2 # Full argument pinning: a copy-paste swap between the two near-identical - # calls (mirrors, destination) must not stay green - fw_call, tc_call = mock_install.call_args_list - assert fw_call.args == ( - framework.FRAMEWORK_PACKAGE, - "3.30102.0", - tmp_path / "frameworks" / "3.30102.0", - framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, - tmp_path / "downloads", - ) - assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries") - assert tc_call.args == ( - framework.TOOLCHAIN_PACKAGE, - framework.TOOLCHAIN_VERSION, - tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION, - framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, - tmp_path / "downloads", - ) - assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf") - # The prefetch sees the same package specs as the installs - assert mock_prefetch.call_args.args == ( - [ + # specs (mirrors, destination) must not stay green + assert mock_install.call_args.args == ( + ( ( framework.FRAMEWORK_PACKAGE, - "3.30102.0", - tmp_path / "frameworks" / "3.30102.0", + _recommended().tag, + tmp_path / "frameworks" / _recommended().tag, framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, + ("cores/esp8266", "tools/sdk", "libraries"), ), ( framework.TOOLCHAIN_PACKAGE, framework.TOOLCHAIN_VERSION, tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION, framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, + ("bin", "xtensa-lx106-elf"), ), - ], + ), tmp_path / "downloads", + { + framework.FRAMEWORK_PACKAGE: _recommended().download, + framework.TOOLCHAIN_PACKAGE: framework.toolchain_download, + }, + ) + # One spec list feeds both phases, so they cannot drift + assert mock_prefetch.call_args.args == mock_install.call_args.args + # PackageSpec instances, not bare tuples: the batch header reads .name + assert all( + isinstance(spec, framework.PackageSpec) + for spec in mock_install.call_args.args[0] ) @@ -112,21 +188,10 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None: assert env["CCACHE_DIR"] == "x" -def test_ccache_env(tmp_path: Path) -> None: - assert framework.ccache_env(None) == {} - with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True): - env = framework.ccache_env("/usr/bin/ccache") - # User-set values are respected; the rest get defaults - assert "CCACHE_NOHASHDIR" not in env - assert env["CCACHE_DEPEND"] == "1" - assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve()) - assert env["CCACHE_DIR"].endswith("ccache") - - -def test_check_and_install_rejects_old_core(tmp_path: Path) -> None: - """Calling the installer below the floor fails before any download.""" - with pytest.raises(EsphomeError, match=">= 3.1.1"): - framework.check_and_install(cv.Version(3, 0, 2)) +def test_check_and_install_rejects_unbuilt_core(tmp_path: Path) -> None: + """A core version without a build fails before any download.""" + with pytest.raises(EsphomeError, match=r"3\.1\.1.*available: 3\.1\.2"): + framework.check_and_install(cv.Version(3, 1, 1)) def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None: @@ -148,18 +213,20 @@ def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None: assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"] -def test_ccache_env_accepts_a_preresolved_path() -> None: - """The caller resolves ccache once and threads it through; None means - resolved-and-disabled.""" - with patch.dict(os.environ, {}, clear=True): - assert framework.ccache_env(None) == {} - env = framework.ccache_env("/usr/bin/ccache") - assert env["CCACHE_DIR"].endswith("ccache") - - def test_toolchain_tool_layout(tmp_path: Path) -> None: """One owner for the bin/xtensa-lx106-elf- layout.""" tool = framework.toolchain_tool(tmp_path, "addr2line") assert tool.parent == tmp_path / "bin" assert tool.name.startswith("xtensa-lx106-elf-addr2line") assert (tool.suffix == ".exe") is (os.name == "nt") + + +def test_get_build_env_uses_the_arduino8266_ccache_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(tmp_path / "cache")) + monkeypatch.delenv("CCACHE_DIR", raising=False) + env = framework.get_build_env(tmp_path / "toolchain", "/usr/bin/ccache") + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + # None means resolved and disabled + assert "CCACHE_DIR" not in framework.get_build_env(tmp_path / "toolchain", None) diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py new file mode 100644 index 00000000000..62863744181 --- /dev/null +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -0,0 +1,452 @@ +"""Tests for esphome.arduino8266.toolchain (the ninja build driver).""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.arduino8266 import framework, toolchain +import esphome.config_validation as cv +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + Toolchain, +) +from esphome.core import CORE, EsphomeError + +_SIZE_OUTPUT = """\ +firmware.elf : +section size addr +.data 1924 1073643520 +.noinit 56 1073645444 +.text 496 1074790400 +.irom0.text 342804 1075843088 +.text1 27489 1074790896 +.rodata 2588 1073645504 +.bss 26504 1073648096 +Total 401861 +""" + + +@pytest.fixture(autouse=True) +def _setup_core(tmp_path: Path) -> None: + CORE.name = "test8266" + CORE.config_path = tmp_path / "test8266.yaml" + CORE.build_path = tmp_path + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} + # run_compile verifies the produced artifacts; give every test a build + # that "produced" them (tests for the guard delete them again). The + # manifest comes first: artifacts must not be older than build.ninja. + build_dir = CORE.relative_pioenvs_path("test8266") + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "build.ninja").write_text("# manifest") + for artifact in ( + "firmware.elf", + "firmware.bin", + "firmware.factory.bin", + "firmware.ota.bin", + ): + (build_dir / artifact).write_bytes(b"") + + +def _paths(tmp_path: Path) -> framework.InstalledPaths: + return framework.InstalledPaths( + framework=tmp_path / "framework", + toolchain=tmp_path / "toolchain", + ninja=tmp_path / "ninja", + ) + + +def test_path_getters(tmp_path: Path) -> None: + assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266") + assert toolchain.get_elf_path().name == "firmware.elf" + # The framework accessor owns the layout and the Windows suffix + suffix = ".exe" if os.name == "nt" else "" + assert toolchain.get_addr2line_path().name == f"xtensa-lx106-elf-addr2line{suffix}" + assert toolchain.get_objdump_path().name == f"xtensa-lx106-elf-objdump{suffix}" + assert toolchain.get_readelf_path().name == f"xtensa-lx106-elf-readelf{suffix}" + + +def test_run_compile_build_failure(tmp_path: Path) -> None: + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, "run", return_value=MagicMock(returncode=2) + ) as mock_run, + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2 + cmd = mock_run.call_args[0][0] + assert "-v" in cmd + # The compile database is generated before the build runs, so a failed + # build cannot leave a stale database behind. + mock_compdb.assert_called_once() + + +def test_run_compile_success(tmp_path: Path) -> None: + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=False), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ) as mock_run, + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary") as mock_size, + patch.object(toolchain, "get_idedata") as mock_idedata, + ): + rc = toolchain.run_compile( + {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False + ) + assert rc == 0 + call = next(c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])) + cmd = call[0][0] + # Explicit targets: a manifest missing them fails as "unknown target" + assert {"firmware.factory.bin", "firmware.ota.bin"} <= set(cmd) + assert cmd[cmd.index("-j") + 1] == "4" + # cwd, not -C, so ninja prints no "Entering directory" banner + assert "-C" not in cmd + assert call[1]["cwd"] is not None + # An unchanged manifest is passed on, so the shared refresh can skip + assert mock_compdb.call_args.args[3] is False + mock_size.assert_called_once() + mock_idedata.assert_called_once() + + +def test_run_compile_missing_artifact_fails( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A zero ninja exit that produced no firmware must not be a green + build (size summary and idedata only warn).""" + (toolchain.get_build_dir() / "firmware.elf").unlink() + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands"), + patch.object(toolchain, "_print_size_summary") as mock_size, + patch.object(toolchain, "get_idedata"), + ): + rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) + assert rc == 1 + assert "Build produced no" in caplog.text + mock_size.assert_not_called() + + +def test_run_compile_warns_when_idedata_fails( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed idedata generation right after a successful build is visible, + not deferred to a misleading error in a later command.""" + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands"), + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata", return_value=None), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "No idedata was generated for this build" in caplog.text + + +def test_parse_app_size(tmp_path: Path) -> None: + ld = tmp_path / "eagle.flash.4m.ld" + ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) == 0xFEFF0 + + ld.write_text("MEMORY { }\n") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None + + # A zero-length segment is bad data, not a budget; warn and drop it + ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0x0\n}\n") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None + + with patch( + "esphome.build_gen.arduino8266.get_flash_ld_path", + return_value=tmp_path / "missing.ld", + ): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None + + +def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT), + ), + patch.object(toolchain, "_parse_app_size", return_value=1044464), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + out = capsys.readouterr().out + # Exact PlatformIO shape so script/ci_memory_impact_extract.py can parse it + assert "RAM: [==== ] 37.9% (used 31016 bytes from 81920 bytes)" in out + assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out + + +def test_print_size_summary_missing_size_tool_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A missing size binary degrades to a warning; the firmware already + linked and must not be discarded.""" + with patch.object( + toolchain.subprocess, "run", side_effect=FileNotFoundError("no size") + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert "Could not summarize firmware size" in caplog.text + + +def test_print_size_summary_no_app_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT), + ), + patch.object(toolchain, "_parse_app_size", return_value=None), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + out = capsys.readouterr().out + # Both lines are skipped together: a RAM line without Flash would skew + # CI's memory-impact sums across builds + assert out == "" + + +def test_print_size_summary_size_tool_failure( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + with patch.object( + toolchain.subprocess, + "run", + side_effect=subprocess.CalledProcessError(1, "size", stderr="bad elf"), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert capsys.readouterr().out == "" + assert "Could not summarize firmware size" in caplog.text + + +def test_get_idedata_delegates(tmp_path: Path) -> None: + with ( + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", + return_value={"cc_path": "x"}, + ) as mock_load, + patch.object( + toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache" + ), + ): + assert toolchain.get_idedata() == {"cc_path": "x"} + compile_commands, elf, cache = mock_load.call_args[0] + assert compile_commands.name == "compile_commands.json" + assert elf.name == "firmware.elf" + assert cache.name == "test8266.arduino.json" + # The exact configured launcher string is passed for compile DB parsing + # (resolve_ccache_path returns a str, untouched on every platform) + assert mock_load.call_args.kwargs["launcher"] == "/cc/ccache" + + +def test_get_idedata_no_ccache(tmp_path: Path) -> None: + with ( + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", return_value={} + ) as mock_load, + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), + ): + toolchain.get_idedata() + assert mock_load.call_args.kwargs["launcher"] is None + + +def test_print_size_summary_unparsable_section( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A totals-relevant section that fails to parse must not produce a + confident wrong number; an irrelevant one is ignored.""" + bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc") + with patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=bad), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert capsys.readouterr().out == "" + assert "missing section(s) .bss" in caplog.text + + caplog.clear() + harmless = _SIZE_OUTPUT + ".broken abc 0\n" + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=harmless), + ), + patch.object(toolchain, "_parse_app_size", return_value=1044464), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert "RAM:" in capsys.readouterr().out + assert not caplog.text + + +def test_print_size_summary_missing_section_skips_summary( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A totals section absent from the output must not default to zero.""" + without_bss = "\n".join( + line for line in _SIZE_OUTPUT.splitlines() if ".bss" not in line + ) + with patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=without_bss), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert capsys.readouterr().out == "" + assert "missing section(s) .bss" in caplog.text + + +def test_run_compile_idedata_error_does_not_fail_build( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unusable compile DB after a successful build warns, never fails.""" + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands"), + patch.object(toolchain, "_print_size_summary"), + patch.object( + toolchain, + "get_idedata", + side_effect=EsphomeError("compile database is unusable"), + ), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "Could not generate idedata: compile database is unusable" in caplog.text + + +def test_run_compile_skipped_size_summary_names_consequence( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A skipped RAM/Flash summary warns about the missing metric; the + per-cause warnings alone are invisible to CI harnesses.""" + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands"), + patch.object(toolchain, "_print_size_summary", return_value=False), + patch.object(toolchain, "get_idedata", return_value=None), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "Firmware size summary unavailable for this build" in caplog.text + + +def test_parse_app_size_non_utf8_ld_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A corrupt (non-UTF-8) linker script degrades to the same warning as + an unreadable one, never a traceback after a successful link.""" + paths = _paths(tmp_path) + ld = tmp_path / "corrupt.ld" + ld.write_bytes(b"\xff\xfe not utf8") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, paths) is None + assert "Cannot read linker script" in caplog.text + + +def test_get_idedata_accepts_preresolved_ccache() -> None: + """run_compile threads its resolved ccache through; the probe must not + run again.""" + with ( + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", + return_value={"ok": True}, + ) as mock_build, + patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve, + ): + assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True} + mock_resolve.assert_not_called() + assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache" + + +def test_run_compile_warns_about_dropped_platformio_options( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Component-added options the native build drops are warned by name; + the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, + flash_mode) stay quiet.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.platformio_options = { + "board_build.ldscript": "eagle.flash.4m2m.ld", + "board_build.f_cpu": "160000000L", + "board_build.filesystem": "littlefs", + "board_build.flash_mode": "dio", + "build_src_flags": "-include throw_stubs.h", + "lib_ignore": ["Updater"], + "upload_speed": "460800", + } + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=True), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "platformio_options->upload_speed is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "board_build.ldscript is ignored" not in caplog.text + assert "board_build.f_cpu is ignored" not in caplog.text + assert "lib_ignore" not in caplog.text + assert "build_src_flags" not in caplog.text + assert "flash_mode" not in caplog.text + # A rewritten manifest is passed on, so the compile DB is regenerated + assert mock_compdb.call_args.args[3] is True diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 87de28cf327..77b30363ecc 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -1160,3 +1160,29 @@ def test_versionless_dependency_with_provider_stays_quiet( libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] assert "has no version to resolve" not in caplog.text + + +def test_resolve_libraries_without_framework_is_all_external() -> None: + """The host build has no core tree: nothing is bundled, the framework + check is skipped, and every name reaches the converter.""" + _add_library("Wire", None) + _add_library("lvgl/lvgl", "9.5.0") + + def fake_convert(libraries: list, backend: LibraryBackend) -> list: + assert backend.platform == "native" + assert backend.framework is None + assert backend.cache_key == "host" + assert backend.provides("Wire") is False + return [] + + with patch.object(component, "convert_libraries", side_effect=fake_convert) as mock: + libs = component.resolve_libraries( + None, + pio_platform="native", + board_mcu="host", + cache_key="host", + framework=None, + ) + assert libs == [] + (libraries, _backend), _ = mock.call_args + assert [lib.name for lib in libraries] == ["Wire", "lvgl/lvgl"] diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index a377cf185a8..3a902a429f6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1,20 +1,40 @@ """Tests for esphome.automation module.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, call, patch +from collections.abc import Callable, Generator +from functools import partial +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + ApplyAction, + ApplyCall, + ApplyCondition, + ApplyField, CallbackAutomation, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + literal_with_length, + maybe_simple_id, + register_apply_action, + register_apply_condition, + register_bare_action, + register_bare_condition, + register_parented_action, + register_parented_condition, + register_simple_action, + register_simple_condition, ) +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression -from esphome.util import RegistryEntry +from esphome.util import Registry, RegistryEntry def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]: @@ -475,3 +495,497 @@ async def test_build_callback_automations_defaults( mock_build_callback.assert_called_once_with( parent, "add_on_press_callback", [], conf, forwarder=None ) + + +PARENT_ID = ID("my_component") +PARENT_OBJ = MockObj("parent", "->") +NEW_OBJ = MockObj("var", "->") +ACTION_TYPE = cg.esphome_ns.class_("MyAction") +CONDITION_TYPE = cg.esphome_ns.class_("MyCondition") +TEMPLATE_ARG = cg.TemplateArguments() + + +class MockCodegen(NamedTuple): + get_variable: AsyncMock + new_pvariable: MagicMock + register_parented: AsyncMock + add_global: MagicMock + calls: MagicMock # new_pvariable and add_global attached, to check their order + + +@pytest.fixture +def mock_cg() -> Generator[MockCodegen]: + """Patch the codegen calls the shared builders make.""" + with ( + patch("esphome.codegen.get_variable", new_callable=AsyncMock) as get_variable, + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch( + "esphome.codegen.register_parented", new_callable=AsyncMock + ) as register_parented, + patch("esphome.cpp_generator.add_global") as add_global, + ): + get_variable.return_value = PARENT_OBJ + new_pvariable.return_value = NEW_OBJ + calls = MagicMock() + calls.attach_mock(new_pvariable, "new_pvariable") + calls.attach_mock(add_global, "add_global") + yield MockCodegen( + get_variable, new_pvariable, register_parented, add_global, calls + ) + + +@pytest.fixture +def registries() -> Generator[tuple[Registry, Registry]]: + """Patch both registries so registrations made by a test do not leak.""" + actions = Registry() + conditions = Registry() + with ( + patch("esphome.automation.ACTION_REGISTRY", actions), + patch("esphome.automation.CONDITION_REGISTRY", conditions), + ): + yield actions, conditions + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("register", "is_action", "ctor_parent", "parented"), + [ + (partial(register_simple_action, synchronous=True), True, True, False), + (partial(register_bare_action, synchronous=True), True, False, False), + (partial(register_parented_action, synchronous=True), True, False, True), + (register_simple_condition, False, True, False), + (register_bare_condition, False, False, False), + (register_parented_condition, False, False, True), + ], + ids=[ + "simple_action", + "bare_action", + "parented_action", + "simple_condition", + "bare_condition", + "parented_condition", + ], +) +async def test_shared_builders( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + register: Callable[..., None], + is_action: bool, + ctor_parent: bool, + parented: bool, +) -> None: + """Each helper constructs the object and wires the parent the way its C++ shape needs.""" + actions, conditions = registries + type_id = ACTION_TYPE if is_action else CONDITION_TYPE + register("my.entry", type_id, {}) + entry = (actions if is_action else conditions)["my.entry"] + assert entry.type_id is type_id + config = {CONF_ID: PARENT_ID} if ctor_parent or parented else {} + + result = await entry.fun(config, ID("obj_1"), TEMPLATE_ARG, []) + + assert result is NEW_OBJ + if ctor_parent: + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.new_pvariable.assert_called_once_with( + ID("obj_1"), TEMPLATE_ARG, PARENT_OBJ + ) + else: + mock_cg.get_variable.assert_not_called() + mock_cg.new_pvariable.assert_called_once_with(ID("obj_1"), TEMPLATE_ARG) + if parented: + mock_cg.register_parented.assert_awaited_once_with(NEW_OBJ, PARENT_ID) + else: + mock_cg.register_parented.assert_not_called() + + +@pytest.mark.parametrize("synchronous", [True, False]) +def test_shared_builders_keep_synchronous_flag( + registries: tuple[Registry, Registry], synchronous: bool +) -> None: + """The synchronous flag reaches the registry entry unchanged.""" + actions, _ = registries + register_simple_action("my.simple", ACTION_TYPE, {}, synchronous=synchronous) + register_bare_action("my.bare", ACTION_TYPE, {}, synchronous=synchronous) + register_parented_action("my.parented", ACTION_TYPE, {}, synchronous=synchronous) + assert actions["my.simple"].synchronous is synchronous + assert actions["my.bare"].synchronous is synchronous + assert actions["my.parented"].synchronous is synchronous + + +async def _run_entry( + entry: RegistryEntry, + config: dict[str, object], + args: list[tuple[object, str]] | None, + platform: str, + id_key: str = CONF_ID, +) -> RegistryEntry: + """Run a registered builder with the given config, trigger args and platform.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + args = args or [] + template_arg = cg.TemplateArguments(*(t for t, _ in args)) + await entry.fun({id_key: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + return entry + + +async def _run_apply_action( + registries: tuple[Registry, Registry], + fields: tuple[ApplyField | ApplyCall, ...], + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + call: str | None = None, + platform: str = "esp32", + id_key: str = CONF_ID, +) -> RegistryEntry: + """Register an apply action and run its builder with the given config.""" + actions, _ = registries + register_apply_action("my.apply", None, *fields, call=call, id_key=id_key) + return await _run_entry(actions["my.apply"], config, args, platform, id_key) + + +async def _run_apply_condition( + registries: tuple[Registry, Registry], + check: str | ApplyCall, + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + platform: str = "esp32", + id_key: str = CONF_ID, +) -> RegistryEntry: + """Register an apply condition and run its builder with the given config.""" + _, conditions = registries + register_apply_condition("my.check", None, check, id_key=id_key) + return await _run_entry(conditions["my.check"], config, args, platform, id_key) + + +def _apply_definition(mock_cg: MockCodegen) -> str: + return str(mock_cg.add_global.call_args.args[0]) + + +@pytest.mark.asyncio +async def test_register_apply_action_entry( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_action(registries, (), {}, args=[(cg.int32, "x")]) + assert entry.type_id is ApplyAction + assert entry.synchronous is True + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + action_id, template_arg = mock_cg.new_pvariable.call_args.args + assert action_id == ID("obj_1") + assert str(template_arg) == "" + # The definition must precede the storage line that names the function. + assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"] + assert _apply_definition(mock_cg).startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) + + +@pytest.mark.asyncio +async def test_apply_custom_id_key( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + await _run_apply_action(registries, (), {}, id_key="transmitter_id") + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.get_variable.reset_mock() + await _run_apply_condition(registries, "is_on()", {}, id_key="transmitter_id") + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + + +@pytest.mark.asyncio +async def test_apply_constants( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """Constants are immediates, strings stay in flash, absent keys emit nothing, order is kept.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ApplyField("on", "set_on", cg.bool_), + ApplyField("song", "play", cg.std_string), + ApplyField("position", "position = {}", cg.float_), + ApplyCall("publish_state()"), + ) + config = {"kp": 0.0, "on": False, "song": "a:b", "position": 0.5} + await _run_apply_action(registries, fields, config) + text = _apply_definition(mock_cg) + lines = [ + f"::{PARENT_OBJ}->set_kp(0.0f);", + f"::{PARENT_OBJ}->set_on(false);", + f'::{PARENT_OBJ}->play("a:b");', + f"::{PARENT_OBJ}->position = 0.5f;", + f"::{PARENT_OBJ}->publish_state();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + assert "set_ki" not in text + + +@pytest.mark.asyncio +async def test_apply_id_constant_is_the_named_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A templatable use_id given as a plain id renders the object it names.""" + target = MockObj("speaker_b", "->") + mock_cg.get_variable.side_effect = [PARENT_OBJ, target] + fields = (ApplyField("target", "switch_to_output", cg.RawExpression("Speaker *")),) + await _run_apply_action(registries, fields, {"target": ID("speaker_b")}) + mock_cg.get_variable.assert_any_await(ID("speaker_b")) + assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in str( + _apply_definition(mock_cg) + ) + + +@pytest.mark.asyncio +async def test_apply_condition_id_constant_is_the_named_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """The condition path resolves and qualifies an id constant the same way.""" + target = MockObj("speaker_b", "->") + mock_cg.get_variable.side_effect = [PARENT_OBJ, target] + check = ApplyCall("is_output({})", (("target", cg.RawExpression("Speaker *")),)) + await _run_apply_condition(registries, check, {"target": ID("speaker_b")}) + mock_cg.get_variable.assert_any_await(ID("speaker_b")) + assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in str( + _apply_definition(mock_cg) + ) + + +@pytest.mark.asyncio +async def test_apply_lambdas( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A single return reduces to a cast, anything longer is called inline with the trigger args.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ) + config = { + "kp": Lambda("return x * 2;"), + "ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"), + } + await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) + text = _apply_definition(mock_cg) + assert text.startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) + # The parent is global-scope qualified, so an arg named like the id cannot shadow it. + assert f"::{PARENT_OBJ}->set_kp(" in text + assert f"::{PARENT_OBJ}->set_kp(static_cast(x * 2));" in text + # Outer apply lambda and inner field lambda spell the trigger arg identically. + assert text.count("const std::remove_cvref_t & x") == 2 + assert ( + f"::{PARENT_OBJ}->set_ki([](const std::remove_cvref_t & x) -> float {{" + in text + ) + assert "}(x));" in text + + +@pytest.mark.asyncio +async def test_apply_call_keys( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A multi-key call needs all keys, is skipped with none, and errors on a partial set.""" + fields = ( + ApplyCall("set_range({}, {})", (("low", cg.float_), ("high", cg.float_))), + ) + await _run_apply_action(registries, fields, {"low": 1.0, "high": 2.0}) + assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_definition(mock_cg) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {}) + assert "set_range" not in _apply_definition(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_action(registries, fields, {"low": 1.0}) + + +@pytest.mark.asyncio +async def test_apply_action_call_shape( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = (ApplyField("brightness", "set_brightness", cg.float_),) + await _run_apply_action(registries, fields, {"brightness": 0.5}, call="make_call") + text = _apply_definition(mock_cg) + lines = [ + f"auto apply_call = ::{PARENT_OBJ}->make_call();", + "apply_call.set_brightness(0.5f);", + "apply_call.perform();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + + +@pytest.mark.asyncio +async def test_apply_field_nested_key_const_fn_and_type_string( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = ( + ApplyField(("vertical", "direction"), "set_direction", cg.int_), + ApplyField( + "name", + "set_name", + cg.std_string, + const_fn=lambda config, value: f"{cg.safe_exp(value)}, {len(value)}", + ), + ApplyField("value", "value() = {}", "decltype({parent}->value())"), + ) + config = { + "vertical": {"direction": 3}, + "name": "abc", + "value": Lambda("return 42;"), + } + await _run_apply_action(registries, fields, config) + text = _apply_definition(mock_cg) + assert f"::{PARENT_OBJ}->set_direction(3);" in text + assert f'::{PARENT_OBJ}->set_name("abc", 3);' in text + assert ( + f"::{PARENT_OBJ}->value() = static_castvalue())>(42);" + in text + ) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields[:1], {}) + assert "set_direction" not in _apply_definition(mock_cg) + + +def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: + with pytest.raises(ValueError, match="2 placeholder"): + ApplyCall("set_range({}, {})", (("low", cg.float_),)) + with pytest.raises(ValueError, match="only bare"): + ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)) + ApplyCall("set_flags({{{}}})", (("flags", cg.int_),)) + with pytest.raises(ValueError, match="each arg is"): + ApplyCall("set_kp({})", (("kp", cg.float_, None, "extra"),)) + schema = cv.Schema({cv.Required(CONF_ID): cv.string, cv.Optional("kp"): cv.float_}) + register_apply_action("my.ok", schema, ApplyField("kp", "set_kp", cg.float_)) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_action("my.bad", schema, ApplyField("kd", "set_kd", cg.float_)) + register_apply_condition( + "my.is", schema, ApplyCall("kp == {}", (("kp", cg.float_),)) + ) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_condition( + "my.bad_is", schema, ApplyCall("kd == {}", (("kd", cg.float_),)) + ) + with pytest.raises(ValueError, match="'parent_id' is not in the schema"): + register_apply_action("my.bad_id", schema, id_key="parent_id") + with pytest.raises(ValueError, match="'parent_id' is not in the schema"): + register_apply_condition("my.bad_is_id", schema, "is_on()", id_key="parent_id") + either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) + register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) + for wrapped in ( + maybe_simple_id(schema), + maybe_simple_id(schema.schema), + cv.All(schema), + cv.maybe_simple_value(schema, key="kp"), + ): + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_action( + "my.bad", wrapped, ApplyField("kd", "set_kd", cg.float_) + ) + nested = cv.Schema( + { + cv.Required(CONF_ID): cv.string, + cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_}), + } + ) + register_apply_action( + "my.nested", nested, ApplyField(("v", "dir"), "set_dir", cg.int_) + ) + with pytest.raises(ValueError, match="'dri' is not in the schema"): + register_apply_action( + "my.bad2", nested, ApplyField(("v", "dri"), "set_dir", cg.int_) + ) + + +@pytest.mark.asyncio +async def test_apply_string_constant_stays_in_flash_on_esp8266( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = (ApplyField("song", "play", cg.std_string),) + await _run_apply_action(registries, fields, {"song": "a:b"}, platform="esp8266") + assert ( + f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' + in _apply_definition(mock_cg) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_literal_with_length_is_plain_on_every_platform( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + """A (const char *, size_t) target gets the RAM literal and its byte length, never a flash copy.""" + fields = ( + ApplyField("option", "set_option", cg.std_string, const_fn=literal_with_length), + ) + await _run_apply_action( + registries, fields, {"option": "h\u00e9llo"}, platform=platform + ) + text = _apply_definition(mock_cg) + assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text + assert "progmem_string" not in text + + +@pytest.mark.asyncio +async def test_register_apply_condition_predicate( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_condition( + registries, "is_playing()", {}, args=[(cg.int32, "x")] + ) + assert entry.type_id is ApplyCondition + condition_id, template_arg = mock_cg.new_pvariable.call_args.args + assert condition_id == ID("obj_1") + assert str(template_arg) == "" + text = _apply_definition(mock_cg) + assert text.startswith( + "static bool esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) + assert f"return ::{PARENT_OBJ}->is_playing();" in text + + +@pytest.mark.asyncio +async def test_apply_condition_compares_config_value( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + check = ApplyCall("state == {}", (("state", cg.bool_),)) + await _run_apply_condition(registries, check, {"state": True}) + assert f"return ::{PARENT_OBJ}->state == true;" in _apply_definition(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_condition(registries, check, {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_condition_string_constant_is_a_plain_literal( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) + assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "expected", "called"), + [ + ("return x;", "->state == (x);", False), + ('return x.empty() ? "e" : x;', '->state == (x.empty() ? "e" : x);', False), + ('if (x.empty()) return "e";\nreturn x;', "}(x);", True), + ], +) +async def test_apply_condition_string_lambda_paths( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + body: str, + expected: str, + called: bool, +) -> None: + """A single return is inlined with no copy; a longer body is a called std::string lambda.""" + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition( + registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] + ) + text = _apply_definition(mock_cg) + assert expected in text + assert ("-> std::string {" in text) is called diff --git a/tests/unit_tests/test_codegen.py b/tests/unit_tests/test_codegen.py index 8d01fef7c20..c5c971b22dc 100644 --- a/tests/unit_tests/test_codegen.py +++ b/tests/unit_tests/test_codegen.py @@ -70,7 +70,6 @@ from esphome.cpp_generator import _extract_component_ns "arduino_json_ns", "JsonObject", "JsonObjectConst", - "Controller", "GPIOPin", ), ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 457b9d017b8..230a8e1f9ef 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -1394,6 +1402,35 @@ def test_entity_metadata_visibility_hints() -> None: assert web["web_server"].visibility is advanced +def test_with_visibility_remarks_keys() -> None: + """``with_visibility`` re-marks the named keys, preserving each field's + default and validator, without touching the other keys or the input schema. + """ + base = cv.Schema( + { + cv.Optional("a", default=7): cv.int_, + cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string, + } + ) + promoted = cv.with_visibility(base, cv.Visibility.UI, "a") + + pm = {str(k): k for k in promoted.schema} + assert pm["a"].visibility is cv.Visibility.UI # re-marked + assert pm["a"].default() == 7 # default preserved + assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched + assert promoted({}) == {"a": 7} # validator/default still applied + + # The input schema is left untouched (no shared-marker mutation). + assert {str(k): k for k in base.schema}["a"].visibility is None + + +def test_with_visibility_unknown_key_raises() -> None: + """A key not present in the schema is a typo — fail at build time.""" + base = cv.Schema({cv.Optional("a"): cv.int_}) + with pytest.raises(ValueError, match="not in schema"): + cv.with_visibility(base, cv.Visibility.UI, "nope") + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) @@ -3145,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv") diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0c96f8c8c99..94458109753 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -976,6 +976,14 @@ class TestEsphomeCore: target.toolchain = const.Toolchain.SDK_NRF assert target.using_native_toolchain is False + def test_using_toolchain_host(self, target): + """The host's only toolchain: the system compiler driven by ninja.""" + target.toolchain = const.Toolchain.HOST + assert target.using_toolchain_host is True + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_toolchain_host is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py index e12c273294b..0a8fb59cb81 100644 --- a/tests/unit_tests/test_coroutine.py +++ b/tests/unit_tests/test_coroutine.py @@ -1,5 +1,7 @@ """Tests for the coroutine module.""" +import contextvars + import pytest from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority @@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None: # Check execution order assert execution_order == ["core", "custom", "diagnostics"] + + +def test_context_isolated_between_interleaved_tasks() -> None: + """Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it.""" + my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var") + seen: dict[str, str] = {} + + def task_a(): + my_var.set("a") + yield # suspend so task_b can run before task_a resumes + seen["a"] = my_var.get() + + def task_b(): + my_var.set("b") + yield + seen["b"] = my_var.get() + + loop = FakeEventLoop() + loop.add_job(task_a) + loop.add_job(task_b) + loop.flush_tasks() + + assert seen == {"a": "a", "b": "b"} + + +def test_context_inherits_ambient_value_at_schedule_time() -> None: + """Test that a job sees whatever contextvar value was set before it was scheduled.""" + my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var") + token = my_var.set("ambient") + seen: dict[str, str] = {} + + def task(): + seen["value"] = my_var.get() + yield + + try: + loop = FakeEventLoop() + loop.add_job(task) + loop.flush_tasks() + finally: + my_var.reset(token) + + assert seen == {"value": "ambient"} diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 81ae586e23b..fa6255b8c9e 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -85,6 +85,15 @@ class TestCallExpression: assert actual == 'my_function(1, "2", false)' +class TestStaticCastExpression: + def test_str(self): + target = cg.StaticCastExpression(ct.bool_, 42) + + actual = str(target) + + assert actual == "static_cast(42)" + + class TestStructInitializer: def test_str(self): target = cg.StructInitializer( @@ -229,6 +238,94 @@ class TestLambdaExpression: ) +class TestCallLambda: + """Tests for the call_lambda() function.""" + + def test_call_lambda__return_expression_casts_to_return_type(self): + """A lambda body that is just a return statement reduces to the + expression, cast to the lambda's return type.""" + lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.StaticCastExpression) + assert str(result) == "static_cast(foo + 1)" + + def test_call_lambda__return_with_trailing_statements_is_called(self) -> None: + """Only a lone return statement reduces; a longer body is called as is.""" + lamb = cg.LambdaExpression(("return 1;\nfoo();",), (), "", ct.int_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result).endswith("}()") + + def test_call_lambda__braced_return_is_called(self) -> None: + """A braced return needs the lambda's return type, so it is not reduced.""" + lamb = cg.LambdaExpression(("return {};",), (), "", ct.int_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert "static_cast" not in str(result) + + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): + """A class return type is not cast, since static_cast doesn't apply + to arbitrary class types.""" + mock_class = cg.MockObjClass("foo::Bar", parents=()) + lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.RawExpression) + assert str(result) == "get_bar()" + + def test_call_lambda__no_return_with_parameters_calls_with_names(self): + """A multi-statement lambda with parameters is called with the + parameter names as arguments.""" + lamb = cg.LambdaExpression( + ("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_ + ) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == ( + "[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)" + ) + + def test_call_lambda__no_return_type_raises(self): + """Calling a lambda with no declared return type is a developer + error: call_lambda is only for value-returning lambdas.""" + lamb = cg.LambdaExpression(("do_something();",), (), "=") + + with pytest.raises(AssertionError): + cg.call_lambda(lamb) + + def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement( + self, + ): + """A body that merely starts with the substring "return" (e.g. a call + to a function named returnValue()) must not be mistaken for a return + statement -- the match requires a word boundary after "return".""" + lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[=]() -> bool {\n returnValue();\n}()" + + def test_call_lambda__no_return_no_parameters_calls_with_no_args(self): + """A multi-statement lambda without parameters is called with no + arguments.""" + lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[]() -> bool {\n do_something();\n}()" + + class TestLiterals: @pytest.mark.parametrize( "target, expected", diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 1c0e0d0a931..725c1daebb4 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -187,6 +187,31 @@ def test_slot_counter_emits_requested_count() -> None: assert _define_value("TEST_SLOT_COUNT") == "2" +def test_slot_counter_keyed_emits_largest_count() -> None: + """Keyed requests size storage every key declares at the same capacity: + the define is the busiest key's count, not the total over all keys.""" + request = ch.slot_counter("TEST_SLOT_COUNT_KEYED") + request("rx_a") + request("rx_a") + request("rx_a") + request("rx_b") + assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3 + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_KEYED") == "3" + + +def test_slot_counter_rejects_mixed_keyed_and_unkeyed_requests() -> None: + """A keyed and an unkeyed request for one define cannot be sized together.""" + request = ch.slot_counter("TEST_SLOT_COUNT_MIXED") + request("rx_a") + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED"): + request() + unkeyed = ch.slot_counter("TEST_SLOT_COUNT_MIXED_2") + unkeyed() + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED_2"): + unkeyed("rx_a") + + def test_slot_counter_without_requests_emits_nothing() -> None: """No requests, no job, no define — the guarded storage compiles out.""" ch.slot_counter("TEST_SLOT_COUNT_UNUSED") diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 2ccf53f7e3b..0d664b8db66 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -50,3 +50,37 @@ def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) assert all( {"title", "description", "file", "download"} <= entry.keys() for entry in types ) + + +def _nrf52_files(tmp_path: Path, *built: str) -> list[str]: + """The files nrf52 offers for a build directory holding *built*.""" + (tmp_path / "zephyr").mkdir() + for name in built: + (tmp_path / name).touch() + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + return [entry["file"] for entry in _download_types("nrf52", storage)] + + +@pytest.mark.parametrize( + ("built", "expected"), + [ + # Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it. + ( + ["zephyr/zephyr.uf2", "zephyr/app_update.bin"], + ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], + ), + (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]), + ( + ["zephyr/merged.hex", "zephyr/app_update.bin"], + ["zephyr/merged.hex", "zephyr/app_update.bin"], + ), + (["zephyr/merged.hex"], ["zephyr/merged.hex"]), + ([], ["zephyr/zephyr.hex"]), + ], +) +def test_nrf52_offers_the_mcumgr_image_whenever_it_was_built( + tmp_path: Path, built: list[str], expected: list[str] +) -> None: + """The mcumgr update image is offered beside a UF2 as well as beside a HEX.""" + assert _nrf52_files(tmp_path, *built) == expected diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 8ca36254da9..eb783c7e70a 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -1,5 +1,7 @@ """Tests for esphome.espidf.clang_tidy tidy-project generation.""" +# pylint: disable=protected-access + import json import os from pathlib import Path @@ -211,3 +213,39 @@ def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None: compile_commands.write_text(json.dumps([])) with pytest.raises(RuntimeError, match="tidy.cpp not found"): clang_tidy._idedata_from_tidy_project(compile_commands) + + +@pytest.mark.parametrize( + ("reconfigure_rcs", "error"), + [ + ((1,), "ESP-IDF CMake configure \\(discovery\\) failed"), + ((0, 1), "ESP-IDF CMake configure failed"), + ((0, 0), None), + ], + ids=["discovery", "full", "ok"], +) +def test_generate_compile_commands_configures_twice( + tmp_path: Path, reconfigure_rcs: tuple[int, ...], error: str | None +) -> None: + """Discovery configure, then a configure requiring what it found.""" + with ( + patch.object(clang_tidy, "_setup_core"), + patch.object(clang_tidy, "_convert_pio_libs", return_value={}), + patch.object(clang_tidy, "_write_tidy_project") as mock_write, + patch("esphome.espidf.toolchain.run_reconfigure", side_effect=reconfigure_rcs), + patch( + "esphome.build_gen.espidf.get_available_components", + return_value=["lwip", "esp_timer"], + ), + ): + if error: + with pytest.raises(RuntimeError, match=error): + clang_tidy._generate_compile_commands( + tmp_path, _settings(), tmp_path / "platformio.ini" + ) + return + result = clang_tidy._generate_compile_commands( + tmp_path, _settings(), tmp_path / "platformio.ini" + ) + assert result == tmp_path / "build" / "compile_commands.json" + assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"] diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 3eeace99147..c785d56fa41 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1609,6 +1609,9 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: assert env["CCACHE_NOHASHDIR"] == "true" assert env["CCACHE_DEPEND"] == "1" assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + # The pch cannot cache under ccache without these + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: @@ -1624,8 +1627,8 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: # short-circuits before build_path is needed. p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: - # The canonical off spelling is exported: the raw value is inherited - # by idf.py, where a spelling like "disable" would read as truthy + # The canonical off spelling is exported, so every reader of the + # env sees the same answer assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} @@ -1633,7 +1636,7 @@ def test_ccache_env_opt_in_without_binary( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # Explicit IDF_CCACHE_ENABLE=1 forces it on; without a usable binary - # idf.py silently skips ccache, so this branch must say so out loud. + # IDF's CMake silently skips ccache, so this branch must say so out loud. p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: env = _ccache_env() @@ -1666,7 +1669,7 @@ def test_ccache_env_opt_in_with_working_binary( def test_ccache_env_opt_in_with_rejected_binary( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - # Forced on with a present-but-rejected binary: idf.py does its own + # Forced on with a present-but-rejected binary: IDF's CMake does its own # PATH lookup and uses it anyway; the warning must say so, not claim # the build runs without ccache. # A present but non-executable file: the real probe fails and logs @@ -1683,7 +1686,7 @@ def test_ccache_env_opt_in_with_rejected_binary( ): env = _ccache_env() assert env["IDF_CCACHE_ENABLE"] == "1" - assert "idf.py will use it anyway" in caplog.text + assert "the build will use it anyway" in caplog.text # Exactly one story: the resolver's contradictory "compiling without # ccache" must not precede it assert "compiling without ccache" not in caplog.text @@ -1712,7 +1715,7 @@ def test_ccache_env_idf_knob_unrecognized_warns_and_defers( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver, - and is not forwarded to idf.py as truthy.""" + and is not forwarded to the build as truthy.""" p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") env_vars = {"IDF_CCACHE_ENABLE": "enabled"} with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3: diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py deleted file mode 100644 index e4cc6e137e7..00000000000 --- a/tests/unit_tests/test_espidf_runner.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for esphome.espidf.runner.""" - -from __future__ import annotations - -import io -import os -from pathlib import Path -import subprocess -import sys -import threading - -import pytest - -from esphome.espidf import runner - -# A flushing runner delivers the first line in well under a second; this is -# only ever waited out when the shim has gone back to buffering, so keep it -# just long enough to cover interpreter startup on a loaded CI machine. -FIRST_LINE_TIMEOUT = 10.0 - - -def _prepare_main( - monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str -) -> tuple[io.BytesIO, io.TextIOWrapper]: - """Point ``runner.main()`` at *probe* with a buffered fake stdout. - - ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and - ``os.get_terminal_size``; every one of those is monkeypatched so it is - put back afterwards. The fake stdout is block buffered like a pipe, so - the caller can tell whether the shim flushed. The wrapper comes back with - the buffer because dropping it would close the buffer underneath us. - """ - buf = io.BytesIO() - stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) - - monkeypatch.setattr(sys, "path", list(sys.path)) - monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args]) - monkeypatch.setattr(sys, "stdout", stream) - monkeypatch.setattr(sys, "stderr", stream) - monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) - - return buf, stream - - -def _run_main( - monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str -) -> tuple[io.BytesIO, io.TextIOWrapper]: - """Run ``runner.main()`` against *probe* and expect a clean exit.""" - buf, stream = _prepare_main(monkeypatch, probe, *args) - assert runner.main() == 0 - return buf, stream - - -def test_main_filters_noise_and_flushes_each_write( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """Useful lines reach the stream right away; noisy ones are dropped.""" - buf, _stream = _run_main( - monkeypatch, fixture_path / "espidf" / "filtering_probe.py" - ) - - # Read before any flush of our own: the shim has to have flushed. - output = buf.getvalue().decode("utf-8") - - assert "Compiling main.cpp\n" in output - assert "[2/9] Building C object\n" in output - # Matched by FILTER_IDF_LINES, so they never leave the runner. - assert "Project build complete." not in output - assert "-- Component paths:" not in output - # Held back until the end because no terminator arrived. - assert output.endswith("still going\n") - - -def test_main_keeps_output_after_a_form_feed( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """A form feed is text, not a line break, so nothing after it is lost.""" - buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py") - - assert buf.getvalue().decode("utf-8") == ( - "Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n" - ) - - -def test_main_drains_a_partial_line_when_the_build_dies( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """A build that stops mid line must still show that line. - - This is the whole point of draining: the message explaining why the - build failed is exactly the one most likely to arrive without a - trailing newline. - """ - buf, _stream = _prepare_main( - monkeypatch, fixture_path / "espidf" / "crashing_probe.py" - ) - - with pytest.raises(SystemExit) as excinfo: - runner.main() - - assert excinfo.value.code == 2 - assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n" - - -def test_main_reports_rather_than_raises_when_draining_fails( - monkeypatch: pytest.MonkeyPatch, - fixture_path: Path, - capfd: pytest.CaptureFixture[str], -) -> None: - """A stream that closed under us must not crash the runner's cleanup. - - The drain runs from a ``finally``, so an exception there would replace - whatever exit code the build was carrying back. - """ - _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") - - assert runner.main() == 0 - reported = capfd.readouterr().err - assert "Could not write out remaining output" in reported - # The held line has to come along; the stream it was meant for is gone. - assert "partial before close" in reported - - -def test_main_survives_a_drain_failure_with_nowhere_to_report_it( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """With no real stderr to report to, cleanup still must not raise. - - ``sys.__stderr__`` is None on some interpreters, and ``print(file=None)`` - falls back to ``sys.stdout``, which here is the shim wrapping the stream - that just failed. - """ - monkeypatch.setattr(sys, "__stderr__", None) - _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") - - assert runner.main() == 0 - - -def test_main_still_filters_a_drained_partial_line( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """Releasing a held line does not smuggle noise past the filter.""" - buf, _stream = _run_main( - monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py" - ) - - assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n" - - -def test_main_keeps_everything_in_verbose_mode( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """``-v`` turns the filter off so the noisy lines survive.""" - buf, _stream = _run_main( - monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v" - ) - - output = buf.getvalue().decode("utf-8") - - assert "Project build complete.\n" in output - assert "-- Component paths: /a /b /c\n" in output - # With no filter there is no line buffering, so the partial line goes - # straight through as well. - assert output.endswith("still going") - - -def test_runner_streams_output_before_the_build_finishes( - fixture_path: Path, probe_env: dict[str, str] -) -> None: - """The runner must flush, or a dashboard build looks frozen. - - ``toolchain.py`` spawns the runner as a plain script with no ``-u``, and - hands it a pipe when esphome itself is running under the dashboard. A - pipe is block buffered, so without a flush in the shim's ``write()`` the - output sits in the child until 8 KiB piles up or the build ends. - """ - runner_py = Path(runner.__file__) - probe = fixture_path / "espidf" / "streaming_probe.py" - - with subprocess.Popen( - [sys.executable, str(runner_py), str(probe)], - stdout=subprocess.PIPE, - # Keep stderr: if the runner dies on startup, its traceback is the - # only clue about why no line showed up. - stderr=subprocess.PIPE, - env=probe_env, - text=True, - ) as proc: - assert proc.stdout is not None - assert proc.stderr is not None - first_line: list[str] = [] - reader = threading.Thread( - target=lambda: first_line.append(proc.stdout.readline()), daemon=True - ) - try: - reader.start() - reader.join(FIRST_LINE_TIMEOUT) - still_running = proc.poll() is None - - # The probe sleeps for a minute after writing, so reaching us at - # all means the line was flushed rather than released at exit. - assert first_line == ["Compiling main.cpp\n"], ( - f"runner stderr: {'' if still_running else proc.stderr.read()}" - ) - assert still_running - finally: - proc.kill() - proc.wait() - # Join before leaving the block, so the reader is done rather than - # racing ``Popen`` closing the pipe under it. - reader.join(1.0) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 9deb27d83cb..c012785e892 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -8,11 +8,12 @@ import json import os from pathlib import Path import subprocess -from unittest.mock import patch +from unittest.mock import call, patch import pytest -from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT +import esphome.config_validation as cv from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, @@ -93,15 +94,38 @@ def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch) assert toolchain._get_configured_targets() is None +@pytest.fixture(autouse=True) +def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None: + """Deterministic run_compile: no host ccache probe, no pch work.""" + monkeypatch.setenv("IDF_CCACHE_ENABLE", "0") + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + + def _setup_build(setup_core: Path) -> tuple[Path, Path]: """Point CORE at a build dir; return (compile_commands, idedata cache) paths.""" CORE.name = "test" CORE.build_path = setup_core / "build" / "test" + CORE.data.setdefault(KEY_ESP32, {})[KEY_IDF_VERSION] = cv.Version(5, 5, 5) compile_commands = CORE.relative_build_path("build", "compile_commands.json") cache = CORE.relative_internal_path("idedata", "test.json") return compile_commands, cache +@contextmanager +def _up_to_date_compile(ninja_side_effect=None) -> Iterator[tuple]: + """Patch run_compile's staleness checks to "up to date"; yield the ninja + and print_summary mocks.""" + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "_cache_entries_changed", return_value=False), + patch.object( + toolchain, "_run_ninja", return_value=0, side_effect=ninja_side_effect + ) as mock_ninja, + patch.object(toolchain, "print_summary") as mock_summary, + ): + yield mock_ninja, mock_summary + + def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None: """A newer exclude_components.esphomeinternal stamp forces a reconfigure so components that leave the exclusion set get rediscovered.""" @@ -240,6 +264,7 @@ def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: ) with ( patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain, "_get_idf_tool", return_value="/idf/tools/cmake"), patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run, ): assert toolchain._get_cmake_output(build_dir) == completed.stdout @@ -247,6 +272,9 @@ def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: assert toolchain._get_cmake_output(build_dir) == completed.stdout mock_run.assert_called_once() + # The resolved path, never a bare "cmake": Windows locates the child + # through the parent's PATH, where the IDF-managed cmake is missing. + assert mock_run.call_args.args[0][0] == "/idf/tools/cmake" assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line") @@ -272,28 +300,6 @@ def test_get_cmake_output_missing_build_does_not_resolve_idf_env( mock_run.assert_not_called() -def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: - """The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS.""" - _setup_build(setup_core) - - with ( - patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")), - patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}), - patch.object(toolchain, "_get_idf_tool", return_value="python"), - patch.object(toolchain.subprocess, "run") as mock_run, - ): - mock_run.return_value.returncode = 0 - - toolchain.run_idf_py("build", jobs=2) - env = mock_run.call_args.kwargs["env"] - assert env["IDF_PY_BUILD_JOBS"] == "2" - assert env["PATH"] == "/bin" - - toolchain.run_idf_py("build") - env = mock_run.call_args.kwargs["env"] - assert "IDF_PY_BUILD_JOBS" not in env - - def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> None: """After a successful discovery reconfigure the reference CMakeCache.txt is restamped; cmake does not rewrite it when only properties or plain @@ -318,7 +324,7 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No ), patch("esphome.build_gen.espidf.write_project"), patch.object(toolchain, "run_reconfigure", return_value=0), - patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "_run_ninja", return_value=0), patch.object(toolchain, "print_summary"), ): assert toolchain.run_compile(config, verbose=False) == 0 @@ -343,7 +349,7 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: ), patch("esphome.build_gen.espidf.write_project"), patch.object(toolchain, "run_reconfigure", return_value=0), - patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "_run_ninja", return_value=0), patch.object(toolchain, "print_summary"), ): assert toolchain.run_compile(config, verbose=False) == 0 @@ -369,7 +375,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( def record_write(minimal: bool = False, builtin_components=None) -> None: calls.append(("write_project", minimal)) - def record_reconfigure() -> int: + def record_reconfigure(verbose: bool = False) -> int: nonlocal reconfigures reconfigures += 1 calls.append(("run_reconfigure",)) @@ -384,7 +390,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( ), patch("esphome.build_gen.espidf.write_project", side_effect=record_write), patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure), - patch.object(toolchain, "run_idf_py", return_value=0) as mock_build, + patch.object(toolchain, "_run_ninja", return_value=0) as mock_build, patch.object(toolchain, "print_summary"), ): assert not CORE.testing_mode @@ -414,7 +420,7 @@ def _record_compile_calls( calls: list[tuple] = [] rcs = iter(reconfigure_rcs) - def record_reconfigure() -> int: + def record_reconfigure(verbose: bool = False) -> int: calls.append(("run_reconfigure",)) return next(rcs, 0) @@ -424,6 +430,11 @@ def _record_compile_calls( def record_save(components: list[str]) -> None: calls.append(("save", components)) + def record_ninja(target: str, **kwargs: object) -> int: + if target == "all": + calls.append(("build",)) + return 0 + with ( patch.object(toolchain, "need_reconfigure", return_value=True), patch.object(toolchain, "load_cached_builtin_components", return_value=cached), @@ -436,11 +447,7 @@ def _record_compile_calls( patch.object( toolchain, "_builtin_component_cache_path", return_value=cache_file ), - patch.object( - toolchain, - "run_idf_py", - side_effect=lambda *a, **kw: calls.append(("build",)) or 0, - ), + patch.object(toolchain, "_run_ninja", side_effect=record_ninja), patch.object(toolchain, "print_summary"), ): rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) @@ -623,34 +630,69 @@ def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) assert toolchain.load_cached_builtin_components() is None -def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: - """compile_process_limit is forwarded to run_idf_py as the job limit.""" +@pytest.mark.parametrize("limit", [1, None]) +def test_run_compile_passes_compile_process_limit( + setup_core: Path, limit: int | None +) -> None: + """compile_process_limit is the job limit for both ninja runs.""" _setup_build(setup_core) - config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}} + esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit} + + with _up_to_date_compile() as (mock_run, _): + assert toolchain.run_compile({CONF_ESPHOME: esphome}, verbose=False) == 0 + + assert mock_run.call_args_list == [ + call("all", verbose=False, jobs=limit, progress=True), + call("size", verbose=False, jobs=limit, extra_env=toolchain._size_env()), + ] + + +def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None: + """print_summary receives the size json, partitions.csv, and the built + ELF from get_built_elf_path, which must stay in lockstep with the + project() name in the generated CMakeLists.""" + _setup_build(setup_core) + with _up_to_date_compile() as (_, mock_summary): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + + mock_summary.assert_called_once_with( + CORE.relative_build_path("build", "esp_idf_size.json"), + CORE.relative_build_path("partitions.csv"), + CORE.relative_build_path("build", f"{CORE.name}.elf"), + ) + + +def test_create_elf_copy(setup_core: Path) -> None: + """The built .elf is copied to the firmware.elf dashboard name.""" + _setup_build(setup_core) + src = toolchain.get_built_elf_path() + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(b"elf") + assert toolchain.create_elf_copy() is True + assert toolchain.get_elf_path().read_bytes() == b"elf" + + +def test_create_elf_copy_missing_source(setup_core: Path) -> None: + """A missing built ELF is a warning and False, not a crash.""" + _setup_build(setup_core) + assert toolchain.create_elf_copy() is False + + +def test_run_compile_writes_the_pch_checksum_before_the_build( + setup_core: Path, +) -> None: + _setup_build(setup_core) + order: list[str] = [] with ( - patch.object(toolchain, "need_reconfigure", return_value=False), - patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, - patch.object(toolchain, "print_summary"), + _up_to_date_compile(lambda *a, **k: order.append("build") or 0), + patch( + "esphome.build_gen.espidf.write_pch_checksum", + side_effect=lambda: order.append("checksum"), + ), ): - assert toolchain.run_compile(config, verbose=False) == 0 - - mock_run.assert_called_once_with("build", "size", jobs=1) - - -def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: - """When no compile_process_limit is set, no job limit is passed to idf.py.""" - _setup_build(setup_core) - config = {CONF_ESPHOME: {}} - - with ( - patch.object(toolchain, "need_reconfigure", return_value=False), - patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, - patch.object(toolchain, "print_summary"), - ): - assert toolchain.run_compile(config, verbose=False) == 0 - - mock_run.assert_called_once_with("build", "size", jobs=None) + toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) + assert order[:2] == ["checksum", "build"] def test_get_core_framework_version_from_core_data(): @@ -660,3 +702,409 @@ def test_get_core_framework_version_from_core_data(): CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} assert toolchain._get_core_framework_version() == "5.5.4" + + +@contextmanager +def _fake_tools(env: dict[str, str] | None = None) -> Iterator: + """Stub the IDF env and tool lookup; yield the run_build_tool mock.""" + with ( + patch.object( + toolchain, + "_get_idf_env", + return_value={"PATH": "/bin", "IDF_CCACHE_ENABLE": "0", **(env or {})}, + ), + patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "run_build_tool", return_value=0) as mock_run, + ): + yield mock_run + + +def test_run_reconfigure_cmake_argv_matches_idf_py(setup_core: Path) -> None: + """The configure is the command idf.py runs. + + IDF 5.5.5 tools/idf_py_actions/tools.py ensure_build_directory: cmake + -G Ninja -DPYTHON_DEPS_CHECKED=1 -DPYTHON= -DESP_PLATFORM=1, + then the -D entries (SDKCONFIG, CCACHE_ENABLE), then the real project + path, in the build dir. IDF 6.1 adds -B , the same as the cwd. + """ + _setup_build(setup_core) + sdkconfig = CORE.relative_build_path("sdkconfig.test") + sdkconfig.parent.mkdir(parents=True) + sdkconfig.write_text("") + project = os.path.realpath(CORE.build_path) + + with _fake_tools() as mock_run: + assert toolchain.run_reconfigure() == 0 + + assert mock_run.call_args.args[0] == [ + "/tools/cmake", + "-G", + "Ninja", + "-DPYTHON_DEPS_CHECKED=1", + "-DPYTHON=/tools/python", + "-DESP_PLATFORM=1", + f"-DSDKCONFIG={sdkconfig}", + "-DCCACHE_ENABLE=0", + project, + ] + kwargs = mock_run.call_args.kwargs + assert kwargs["cwd"] == Path(project) / "build" + assert kwargs["cwd"].is_dir() + assert kwargs["env"]["CLICOLOR_FORCE"] == "1" + assert kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES + + +@pytest.mark.parametrize(("ccache", "expected"), [("1", "True"), ("0", "False")]) +def test_run_reconfigure_cmake_argv_matches_idf6_py( + setup_core: Path, ccache: str, expected: str +) -> None: + """IDF 6 idf.py adds -B and formats CCACHE_ENABLE as True/False. + + IDF 6.1 tools/idf_py_actions/tools.py ensure_build_directory. + """ + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 1, 0) + build_dir = Path(os.path.realpath(CORE.build_path)) / "build" + + with _fake_tools({"IDF_CCACHE_ENABLE": ccache}) as mock_run: + assert toolchain.run_reconfigure() == 0 + + assert mock_run.call_args.args[0] == [ + "/tools/cmake", + "-G", + "Ninja", + "-B", + str(build_dir), + "-DPYTHON_DEPS_CHECKED=1", + "-DPYTHON=/tools/python", + "-DESP_PLATFORM=1", + f"-DCCACHE_ENABLE={expected}", + str(build_dir.parent), + ] + + +def test_run_reconfigure_without_sdkconfig_or_filter(setup_core: Path) -> None: + """No sdkconfig file means no SDKCONFIG entry; -v shows every line.""" + _setup_build(setup_core) + with _fake_tools() as mock_run: + assert toolchain.run_reconfigure(verbose=True) == 0 + cmd = mock_run.call_args.args[0] + assert not any(arg.startswith("-DSDKCONFIG=") for arg in cmd) + assert cmd[-2] == "-DCCACHE_ENABLE=0" + assert mock_run.call_args.kwargs["filter_lines"] is None + + +def test_run_reconfigure_failure_removes_cmakecache( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Like idf.py, a failed configure must not leave a cache that looks valid.""" + _setup_build(setup_core) + cache = Path(os.path.realpath(CORE.build_path)) / "build" / "CMakeCache.txt" + cache.parent.mkdir(parents=True) + cache.write_text("") + with ( + _fake_tools() as mock_run, + patch.object(toolchain, "_print_hints") as mock_hints, + ): + mock_run.return_value = 4 + assert toolchain.run_reconfigure() == 4 + assert not cache.exists() + assert "CMake configure failed with exit code 4" in caplog.text + log_path = mock_run.call_args.kwargs["log_path"] + assert log_path == cache.parent / "log" / "cmake_output.log" + mock_hints.assert_called_once_with(log_path) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("1", "1"), + ("on", "1"), + (" Yes ", "1"), + ("0", "0"), + ("", "0"), + ("disable", "0"), + ], +) +def test_cache_entries_ccache_follows_click_booleans( + setup_core: Path, value: str, expected: str +) -> None: + """IDF_CCACHE_ENABLE maps to CCACHE_ENABLE the way idf.py's click flag does.""" + _setup_build(setup_core) + with _fake_tools({"IDF_CCACHE_ENABLE": value}): + assert toolchain._cache_entries() == {"CCACHE_ENABLE": expected} + + +def test_parse_cmakecache(tmp_path: Path) -> None: + cache = tmp_path / "CMakeCache.txt" + cache.write_text( + "# comment\n" + "// help text\n" + "SDKCONFIG:UNINITIALIZED=/a/sdkconfig.test\n" + "CCACHE_ENABLE:UNINITIALIZED=1\n" + "FLAGS:STRING=-DX=1\n" + "\n" + ) + assert toolchain._parse_cmakecache(cache) == { + "SDKCONFIG": "/a/sdkconfig.test", + "CCACHE_ENABLE": "1", + "FLAGS": "-DX=1", + } + + +# The constant -D entries every configure writes (see _configure_defines). +_CONFIGURED = ( + "PYTHON_DEPS_CHECKED:UNINITIALIZED=1\n" + "PYTHON:UNINITIALIZED=/tools/python\n" + "ESP_PLATFORM:UNINITIALIZED=1\n" +) + + +@pytest.mark.parametrize( + ("cache_text", "expected"), + [ + (None, True), + (_CONFIGURED + "CCACHE_ENABLE:UNINITIALIZED=0\n", False), + (_CONFIGURED + "CCACHE_ENABLE:UNINITIALIZED=1\n", True), + ( + _CONFIGURED.replace("/tools/python", "/old/python") + + "CCACHE_ENABLE:UNINITIALIZED=0\n", + True, + ), + (_CONFIGURED, True), + ], + ids=["no_cache", "same", "changed", "python_moved", "missing"], +) +def test_cache_entries_changed( + setup_core: Path, cache_text: str | None, expected: bool +) -> None: + """Mirrors idf.py's _new_cmakecache_entries, plus the cached PYTHON.""" + _setup_build(setup_core) + if cache_text is not None: + cache = Path(os.path.realpath(CORE.build_path)) / "build" / "CMakeCache.txt" + cache.parent.mkdir(parents=True) + cache.write_text(cache_text) + with _fake_tools(): + assert toolchain._cache_entries_changed() is expected + + +@pytest.mark.parametrize( + ("version", "size_ng"), [(cv.Version(5, 5, 5), True), (cv.Version(6, 1, 0), False)] +) +def test_size_env(setup_core: Path, version: cv.Version, size_ng: bool) -> None: + """IDF 5.x idf.py sets ESP_IDF_SIZE_NG; 6.x dropped it (core_ext.py size_target).""" + CORE.data[KEY_ESP32] = {KEY_IDF_VERSION: version} + env = toolchain._size_env() + assert env["ESP_IDF_SIZE_FORCE_TERMINAL"] == "1" + assert env["SIZE_OUTPUT_FORMAT"] == "default" + assert ("ESP_IDF_SIZE_NG" in env) is size_ng + + +def test_run_ninja_matches_idf_py_run_target(setup_core: Path) -> None: + """The command is ninja [-j N] [-v] in the build dir.""" + _setup_build(setup_core) + with _fake_tools() as mock_run: + assert ( + toolchain._run_ninja( + "size", verbose=True, jobs=2, progress=True, extra_env={"A": "b"} + ) + == 0 + ) + assert mock_run.call_args.args[0] == ["/tools/ninja", "-j", "2", "-v", "size"] + kwargs = mock_run.call_args.kwargs + assert kwargs["cwd"] == Path(os.path.realpath(CORE.build_path)) / "build" + assert kwargs["env"]["CLICOLOR_FORCE"] == "1" + assert kwargs["env"]["A"] == "b" + # -v shows every line as it comes, as idf.py does + assert kwargs["filter_lines"] is None + assert kwargs["progress"] is False + + +def test_run_ninja_filters_and_reports_failure( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + _setup_build(setup_core) + with ( + _fake_tools() as mock_run, + patch.object(toolchain, "_print_hints") as mock_hints, + ): + mock_run.return_value = 1 + assert toolchain._run_ninja("all", verbose=False, jobs=None, progress=True) == 1 + log_path = mock_run.call_args.kwargs["log_path"] + assert log_path.name == "ninja_all_output.log" + mock_hints.assert_called_once_with(log_path) + assert mock_run.call_args.args[0] == ["/tools/ninja", "all"] + assert mock_run.call_args.kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES + assert mock_run.call_args.kwargs["progress"] is True + assert "ninja all failed with exit code 1" in caplog.text + + +@pytest.mark.parametrize("reconfigure_rc", [0, 5]) +def test_run_compile_reconfigures_when_cache_entries_change( + setup_core: Path, reconfigure_rc: int +) -> None: + """A changed -D entry (ccache toggled) reconfigures, as idf.py build did.""" + _setup_build(setup_core) + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "_cache_entries_changed", return_value=True), + patch.object( + toolchain, "run_reconfigure", return_value=reconfigure_rc + ) as mock_reconfigure, + patch.object(toolchain, "_run_ninja", return_value=0) as mock_ninja, + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == reconfigure_rc + mock_reconfigure.assert_called_once_with(True) + assert mock_ninja.called is (reconfigure_rc == 0) + + +@pytest.mark.parametrize("failing", ["all", "size"]) +def test_run_compile_stops_on_ninja_failure(setup_core: Path, failing: str) -> None: + """A failed build skips size; either failure skips the summary.""" + _setup_build(setup_core) + with _up_to_date_compile(lambda target, **kw: 7 if target == failing else 0) as ( + mock_ninja, + mock_summary, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 7 + targets = [c.args[0] for c in mock_ninja.call_args_list] + assert targets == (["all"] if failing == "all" else ["all", "size"]) + mock_summary.assert_not_called() + + +@pytest.mark.parametrize("memory_ld_rc", [0, 3]) +def test_run_compile_testing_mode_builds_memory_ld_first( + setup_core: Path, memory_ld_rc: int +) -> None: + """Testing mode builds and patches memory.ld before the main build.""" + _setup_build(setup_core) + CORE.testing_mode = True + targets: list[str] = [] + + def record(target: str, **kwargs: object) -> int: + targets.append(target) + return memory_ld_rc if target.endswith("memory.ld") else 0 + + with ( + _up_to_date_compile(record), + patch.object(toolchain, "_patch_memory_segments") as mock_patch, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == memory_ld_rc + memory_ld = str(Path("esp-idf", "esp_system", "ld", "memory.ld")) + if memory_ld_rc: + assert targets == [memory_ld] + mock_patch.assert_not_called() + else: + assert targets == [memory_ld, "all", "size"] + mock_patch.assert_called_once() + + +@pytest.mark.parametrize("version", [cv.Version(5, 5, 5), cv.Version(6, 1, 0)]) +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({}, {"CLICOLOR_FORCE": "1", "FORCE_COLOR": "1"}), + ({"CLICOLOR_FORCE": "0"}, {"CLICOLOR_FORCE": "0", "FORCE_COLOR": "1"}), + ({"NO_COLOR": "1"}, {}), + ], + ids=["default", "user-value", "no-color"], +) +def test_tool_env_colors( + setup_core: Path, version: cv.Version, env: dict[str, str], expected: dict[str, str] +) -> None: + """IDF 6 RunTool.__call__ color handling, used for every IDF version.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = version + with _fake_tools(env): + result = toolchain._tool_env() + colors = {k: v for k, v in result.items() if k in ("CLICOLOR_FORCE", "FORCE_COLOR")} + assert colors == expected + + +@pytest.mark.parametrize( + ("limit", "env_value", "expected"), + [ + (2, "8", 2), + (None, "8", 8), + (None, "", None), + (None, None, None), + ], + ids=["limit-wins", "env", "empty-env", "unset"], +) +def test_build_jobs_honors_idf_py_build_jobs( + monkeypatch: pytest.MonkeyPatch, + limit: int | None, + env_value: str | None, + expected: int | None, +) -> None: + """compile_process_limit first, else IDF_PY_BUILD_JOBS as idf.py read it.""" + if env_value is None: + monkeypatch.delenv("IDF_PY_BUILD_JOBS", raising=False) + else: + monkeypatch.setenv("IDF_PY_BUILD_JOBS", env_value) + esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit} + assert toolchain._build_jobs({CONF_ESPHOME: esphome}) == expected + + +@pytest.mark.parametrize("value", ["0", "-1", "many"]) +def test_build_jobs_rejects_invalid_idf_py_build_jobs( + monkeypatch: pytest.MonkeyPatch, value: str +) -> None: + """Like idf.py, a value that is not a positive integer is an error.""" + monkeypatch.setenv("IDF_PY_BUILD_JOBS", value) + with pytest.raises(EsphomeError, match="positive integer"): + toolchain._build_jobs({CONF_ESPHOME: {}}) + + +@contextmanager +def _hint_env(tmp_path: Path, **run_kwargs: object) -> Iterator: + with ( + patch.object(toolchain, "_get_idf_tool", return_value="/py"), + patch.object(toolchain, "_get_idf_path", return_value=tmp_path / "idf"), + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain.subprocess, "run", **run_kwargs) as mock_run, + ): + yield mock_run + + +def test_print_hints_shows_idf_advice( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The hint step runs IDF's own generate_hints on the failed tool's log.""" + log = tmp_path / "ninja_all_output.log" + done = subprocess.CompletedProcess([], 0, "HINT: the binary is too big\n", "") + with _hint_env(tmp_path, return_value=done) as mock_run: + toolchain._print_hints(log) + cmd = mock_run.call_args.args[0] + assert cmd[0] == "/py" + assert "generate_hints" in cmd[2] + assert cmd[3:] == [str(tmp_path / "idf" / "tools"), str(log)] + assert "HINT: the binary is too big" in caplog.text + + +@pytest.mark.parametrize( + "run_kwargs", + [ + {"return_value": subprocess.CompletedProcess([], 1, "", "ImportError")}, + {"return_value": subprocess.CompletedProcess([], 0, " \n", "")}, + {"side_effect": subprocess.TimeoutExpired("py", 60)}, + {"side_effect": OSError("gone")}, + ], + ids=["script-fails", "no-hints", "timeout", "oserror"], +) +def test_print_hints_never_fails_the_build( + tmp_path: Path, caplog: pytest.LogCaptureFixture, run_kwargs: dict +) -> None: + """No hints is fine; the hint step must not raise or add warnings.""" + with caplog.at_level("WARNING"), _hint_env(tmp_path, **run_kwargs): + toolchain._print_hints(tmp_path / "log") + assert caplog.records == [] + + +def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> None: + _setup_build(setup_core) + output = "CMAKE_OBJDUMP:FILEPATH=/tools/objdump\nESPHOME_PCH:BOOL=OFF\n" + with patch.object(toolchain, "_get_cmake_output", return_value=output): + assert toolchain.get_cmake_cache_value("ESPHOME_PCH") == "OFF" + assert toolchain.get_cmake_cache_value("ESPHOME_MISSING") is None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 8867e2c215b..2d65e8e0798 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,9 @@ def test_perform_ota_no_auth( "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" in caplog.text ) + # The data phase timeout must outlast the device's 105 s data timeout + mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT) + assert espota2.DATA_PHASE_TIMEOUT > 105.0 @pytest.mark.usefixtures("mock_time") diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 439220f09c6..60f6b28e076 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -218,6 +218,7 @@ def _upload( firmware: bytes, noise_psk: str | None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: device.start() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -231,13 +232,18 @@ def _upload( Path("firmware.bin"), noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) finally: sock.close() def _run_ota( - device: FakeEncryptedDevice, firmware: bytes, tmp_path: Path, noise_psk: str + device: FakeEncryptedDevice, + firmware: bytes, + tmp_path: Path, + noise_psk: str, + plaintext_fallback: bool = True, ) -> int: """Drive the retry loop, which is where the plaintext fallback reconnects.""" path = tmp_path / "firmware.bin" @@ -249,7 +255,7 @@ def _run_ota( None, path, noise_psk=noise_psk, - plaintext_fallback=True, + plaintext_fallback=plaintext_fallback, ) return rc @@ -299,11 +305,69 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises(espota2.OTAError, match="refusing to send the image"): + with pytest.raises( + espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" + ): _upload(device, b"firmware", PSK) device.join_and_check() +def test_allow_plaintext_upload_when_device_does_not_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The explicit opt in sends the image in plaintext to a device that + cannot encrypt, naming the option in the warning.""" + firmware = b"firmware" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any("'allow_plaintext_upload' is set" in r.message for r in caplog.records) + assert not any("2027.3.0" in r.message for r in caplog.records) + assert not any( + "Remove it from the configuration" in r.message for r in caplog.records + ) + + +def test_allow_plaintext_upload_warns_once_device_encrypts( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removal warning appears exactly when it is safe to act on: the + device offered encryption and accepted the key with the option still set.""" + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice() + with caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any( + "Remove it from the configuration now" in r.message for r in caplog.records + ) + with caplog.at_level(logging.WARNING): + caplog.clear() + _upload(FakeEncryptedDevice(), firmware, PSK) + assert not caplog.records + + +def test_allow_plaintext_upload_keeps_wrong_key_failing( + caplog: pytest.LogCaptureFixture, +) -> None: + """The opt in only covers a device that does not offer; a rejected key + never turns into a plaintext upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, require_noise=False) + with ( + caplog.at_level(logging.WARNING), + pytest.raises(espota2.OTAError, match="encryption key correct"), + ): + _upload(device, b"firmware", PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received != b"firmware" + assert not any("plaintext" in r.message for r in caplog.records) + + # Remove before 2027.3.0 def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None: """The api key is tried opportunistically; an older device that cannot @@ -519,3 +583,18 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: assert wrapper.recv(1) == b"A" # reads and decrypts one frame assert wrapper.recv(1) == b"B" # served from the buffer, no new frame wrapper._decrypt.decrypt.assert_called_once() + + +def test_bare_block_refuses_a_device_that_cannot_encrypt( + caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """What the CLI sends for a bare `encryption:` block: a key with neither + fallback. The retry loop never reconnects in plaintext.""" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota(device, b"firmware", tmp_path, PSK, plaintext_fallback=False) + device.join_and_check() + assert rc == 1 + assert device.received != b"firmware" + assert any("refusing to send the image" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index fcc5572f511..f3b182073f2 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -523,6 +523,17 @@ class TestArchiveExtractAll: archive_extract_all(archive, dest) assert (dest / "file.txt").read_text() == "hi" + def test_progress_callback_passed_through(self, tmp_path: Path) -> None: + """The progress kwarg reaches the dispatched extractor.""" + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"})) + dest = tmp_path / "out" + dest.mkdir() + fractions: list[float] = [] + archive_extract_all(archive, dest, progress=fractions.append) + assert fractions[-1] == 1 + assert (dest / "file.txt").read_bytes() == b"hello" + def test_invalid_type_raises_type_error(self) -> None: with pytest.raises(TypeError, match="archive must be"): archive_extract_all(42, ".") # type: ignore[arg-type] @@ -1951,6 +1962,19 @@ class TestTarExtractAllBranches: mock_pb.assert_called_once_with("Extracting") mock_pb.return_value.update.assert_called() + def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None: + """A progress callback wins over progress_header and ends at 1.0.""" + buf = _make_tar([_reg("a.txt"), _reg("b.txt")], {"a.txt": b"x", "b.txt": b"y"}) + fractions: list[float] = [] + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _tar_extract_all( + buf, tmp_path, progress_header="Extracting", progress=fractions.append + ) + mock_pb.assert_not_called() + assert fractions == sorted(fractions) + assert fractions[-1] == 1 + assert (tmp_path / "a.txt").is_file() + # --------------------------------------------------------------------------- # _zip_extract_all — additional branch coverage @@ -1980,6 +2004,19 @@ class TestZipExtractAllBranches: mock_pb.assert_called_once_with("Unzipping") mock_pb.return_value.update.assert_called() + def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None: + """A progress callback wins over progress_header and ends at 1.0.""" + buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")]) + fractions: list[float] = [] + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _zip_extract_all( + buf, tmp_path, progress_header="Unzipping", progress=fractions.append + ) + mock_pb.assert_not_called() + assert fractions == sorted(fractions) + assert fractions[-1] == 1 + assert (tmp_path / "a.txt").is_file() + # --------------------------------------------------------------------------- # _rename_with_retry @@ -2137,6 +2174,20 @@ class TestSevenZipExtractAll: mock_pb.assert_called_once_with("Unpacking 7z") mock_pb.return_value.update.assert_called() + def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None: + """A progress callback wins over progress_header; 7z reports 1.0 once.""" + buf = self._make_7z({"file.txt": b"x"}) + out = tmp_path / "out" + out.mkdir() + fractions: list[float] = [] + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _7z_extract_all( + buf, out, progress_header="Unpacking 7z", progress=fractions.append + ) + mock_pb.assert_not_called() + assert fractions == [1] + assert (out / "file.txt").is_file() + def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None: """Names that resolve as absolute are silently skipped.""" import py7zr @@ -2294,18 +2345,50 @@ def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None: ) -def test_warn_prefetch_failures_names_each_failure( +def test_warn_batch_failures_names_each_failure( caplog: pytest.LogCaptureFixture, ) -> None: """The shared failure loop warns per job with the failure reason.""" - from esphome.framework_helpers import warn_prefetch_failures + from esphome.framework_helpers import warn_batch_failures - warn_prefetch_failures([("toolchain-x@1", OSError("down"))]) + warn_batch_failures( + [("toolchain-x@1", OSError("down"))], "Could not prefetch %s: %s" + ) assert "Could not prefetch toolchain-x@1: down" in caplog.text - warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s") + warn_batch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s") assert "Prefetch of lib failed: gone" in caplog.text +def test_extract_workers_caps_and_clamps() -> None: + """Extraction stops scaling well before high core counts, and a batch + never asks for more workers than it has archives.""" + from esphome.framework_helpers import BATCH_EXTRACT_WORKERS, extract_workers + + with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=64): + assert extract_workers() == BATCH_EXTRACT_WORKERS + assert extract_workers(2) == 2 + with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1): + assert extract_workers(8) == 1 + + +def test_warn_batch_failures_unexpected_error_keeps_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """An unexpected error type is not reduced to a bare message; expected + download failures stay message-only at WARNING.""" + from esphome.framework_helpers import warn_batch_failures + + with caplog.at_level(logging.DEBUG): + warn_batch_failures( + [("pkg", TypeError("bad call")), ("lib", OSError("down"))], + "Could not install %s: %s", + ) + warnings = {r.getMessage(): r for r in caplog.records if r.levelname == "WARNING"} + assert warnings["Could not install pkg: bad call"].exc_info is not None + assert warnings["Could not install lib: down"].exc_info is None + assert "Failure detail" in caplog.text + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ @@ -2353,3 +2436,20 @@ def test_discard_partial_download_logs_undeletable( ): framework_helpers.discard_partial_download(dest) assert "Could not remove" in caplog.text + + +def test_downloaded_bytes_reports_what_is_on_disk(tmp_path: Path) -> None: + """Part file first, then the landed file, both capped at size; else 0.""" + dest = tmp_path / "archive" + assert framework_helpers.downloaded_bytes(dest, 4) == 0 + part = tmp_path / "archive.part" + part.write_bytes(b"ab") + assert framework_helpers.downloaded_bytes(dest, 4) == 2 + part.write_bytes(b"abcdef") + assert framework_helpers.downloaded_bytes(dest, 4) == 4 + part.unlink() + dest.write_bytes(b"abc") + assert framework_helpers.downloaded_bytes(dest, 4) == 3 + assert framework_helpers.downloaded_bytes(dest) == 3 + dest.write_bytes(b"abcdef") + assert framework_helpers.downloaded_bytes(dest, 4) == 4 diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index ff82fa3c80f..5bdecf2fd3a 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1129,6 +1129,26 @@ def test_resolve_ip_address_cache_miss() -> None: assert result[0][4][0] == "192.168.1.100" +@pytest.mark.parametrize("hostname", ["test.local", "example.com"]) +def test_resolve_ip_address_empty_cache_entry_falls_back(hostname: str) -> None: + """An empty CLI cache entry must use normal DNS or mDNS resolution.""" + cache = AddressCache.from_cli_args([f"{hostname}="], [f"{hostname}="]) + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + MockResolver.return_value.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address(hostname, 6053, address_cache=cache) + + MockResolver.assert_called_once_with([hostname], 6053) + assert result[0][4][0] == "192.168.1.100" + + def test_resolve_ip_address_mixed_cached_uncached() -> None: """Test resolution with mix of cached and uncached hosts.""" cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) diff --git a/tests/unit_tests/test_host_toolchain.py b/tests/unit_tests/test_host_toolchain.py new file mode 100644 index 00000000000..4e4e4448016 --- /dev/null +++ b/tests/unit_tests/test_host_toolchain.py @@ -0,0 +1,329 @@ +"""Tests for esphome.host.toolchain (the native host build driver).""" + +from __future__ import annotations + +from collections.abc import Generator +import logging +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + Toolchain, +) +from esphome.core import CORE, EsphomeError +from esphome.host import toolchain +from esphome.host.toolchain import PROGRAM_NAME + + +@pytest.fixture(autouse=True) +def _core(tmp_path: Path) -> None: + CORE.config_path = tmp_path / "dev.yaml" + CORE.build_path = tmp_path + CORE.name = "dev" + CORE.toolchain = Toolchain.HOST + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} + + +def _abs(path: str) -> str: + """What the toolchain makes of a tool path (a drive is added on Windows).""" + return str(Path(path).absolute()) + + +def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CXX", "/opt/clang++") + with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get): + assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),) + + +def test_find_command_keeps_the_override_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``CC="gcc -m32"`` is a command, as make and CMake read it.""" + monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'") + with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get): + assert toolchain.find_command("CC", ("cc",)) == ( + _abs("/usr/bin/gcc"), + "-m32", + "--sysroot=/opt/my sdk", + ) + + +@pytest.mark.parametrize( + ("override", "expected_args"), + [("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))], +) +def test_find_command_drops_a_ccache_prefix( + monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...] +) -> None: + """The build adds ccache itself; the compiler is the word after it.""" + monkeypatch.setenv("CC", override) + with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get): + assert toolchain.find_command("CC", ("cc",)) == ( + _abs("/usr/bin/gcc"), + *expected_args, + ) + + +def test_find_command_accepts_a_compiler_named_ccache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Alone, the word is the program: ccache's compiler links work that way.""" + monkeypatch.setenv("CC", "ccache") + with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get): + assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),) + + +def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None: + """A broken override fails by name instead of silently using another compiler.""" + monkeypatch.setenv("CXX", "nope++") + with ( + patch("shutil.which", return_value=None), + pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"), + ): + toolchain.find_command("CXX", ("g++",)) + + +def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CC", " ") + with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get): + assert toolchain.find_command("CC", ("gcc", "clang")) == ( + _abs("/usr/bin/clang"), + ) + + +def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CC", raising=False) + table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),) + + +def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CC", raising=False) + with ( + patch("shutil.which", return_value=None), + pytest.raises( + EsphomeError, + match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC", + ), + ): + toolchain.find_command("CC", ("gcc", "clang")) + + +def test_find_command_makes_a_relative_hit_absolute( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CC", "./toolchain/gcc") + with patch("shutil.which", return_value="./toolchain/gcc"): + (found,) = toolchain.find_command("CC", ("gcc",)) + assert Path(found) == Path("toolchain/gcc").absolute() + + +def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AR", "ar --plugin x") + with ( + patch("shutil.which", return_value="/usr/bin/ar"), + pytest.raises(EsphomeError, match="AR must name a program without arguments"), + ): + toolchain.find_tool("AR", ("ar",)) + + +def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("CC", "CXX"): + monkeypatch.delenv(var, raising=False) + table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.find_compilers() == toolchain.HostCompilers( + cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),) + ) + + +def test_build_paths(tmp_path: Path) -> None: + """The PlatformIO layout is kept: CORE.firmware_bin resolves the same file.""" + assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev" + assert toolchain.get_elf_path() == CORE.firmware_bin + + +def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("OBJDUMP", "READELF"): + monkeypatch.delenv(var, raising=False) + table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump")) + assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf")) + + +def test_get_build_env_merges_without_leaking( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache")) + monkeypatch.delenv("CCACHE_DIR", raising=False) + monkeypatch.setenv("KEEP_ME", "1") + env = toolchain.get_build_env("/usr/bin/ccache") + assert env["KEEP_ME"] == "1" + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + assert "CCACHE_DIR" not in os.environ + + +def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CCACHE_DIR", raising=False) + assert "CCACHE_DIR" not in toolchain.get_build_env(None) + + +@pytest.fixture +def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]: + """Stub everything run_compile resolves; the build dir holds a manifest.""" + build_dir = tmp_path / ".pioenvs" / "dev" + build_dir.mkdir(parents=True) + (build_dir / "build.ninja").write_text("rule x\n") + compilers = toolchain.HostCompilers(("gcc",), ("g++",)) + with ( + patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")), + patch.object(toolchain, "find_compilers", return_value=compilers), + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), + patch("esphome.build_gen.host.write_project", return_value=True) as project, + patch.object(toolchain, "refresh_compile_commands") as refresh, + patch.object( + toolchain, "_load_idedata", return_value={"cc_path": "gcc"} + ) as ide, + patch("subprocess.run") as run, + ): + yield SimpleNamespace( + build_dir=build_dir, + compilers=compilers, + write_project=project, + refresh=refresh, + idedata=ide, + run=run, + ) + + +def _completed(rc: int = 0) -> SimpleNamespace: + return SimpleNamespace(returncode=rc, stdout="", stderr="") + + +def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None: + elf = compile_env.build_dir / PROGRAM_NAME + + def build(cmd: list[str], **kwargs: object) -> SimpleNamespace: + elf.write_text("") + return _completed() + + compile_env.run.side_effect = build + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} + assert toolchain.run_compile(config, verbose=True) == 0 + + compile_env.write_project.assert_called_once_with(compile_env.compilers, None) + compile_env.refresh.assert_called_once() + assert compile_env.refresh.call_args.args[3] is True + compile_env.run.assert_called_once() + assert compile_env.run.call_args.args[0] == [ + str(Path("/usr/bin/ninja")), + "-v", + "-j", + "4", + PROGRAM_NAME, + ] + assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir + compile_env.idedata.assert_called_once_with(None) + + +def test_run_compile_defaults(compile_env: SimpleNamespace) -> None: + """No verbosity and no process limit: just the ninja target.""" + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert compile_env.run.call_args.args[0] == [ + str(Path("/usr/bin/ninja")), + PROGRAM_NAME, + ] + # The build inherits stdio and never captures: progress must stream + kwargs = compile_env.run.call_args.kwargs + assert kwargs["check"] is False + assert kwargs["close_fds"] is False + assert "capture_output" not in kwargs + + +def test_run_compile_warns_about_dropped_platformio_options( + compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"} + with caplog.at_level(logging.WARNING): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "platformio_options->board_build.f_cpu is ignored" in caplog.text + assert "native 'host' toolchain" in caplog.text + assert "lib_ignore" not in caplog.text + + +def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None: + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + with patch.object( + toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache" + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + compile_env.write_project.assert_called_once_with( + compile_env.compilers, "/usr/bin/ccache" + ) + compile_env.idedata.assert_called_once_with("/usr/bin/ccache") + + +def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None: + compile_env.run.return_value = _completed(rc=3) + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3 + compile_env.idedata.assert_not_called() + + +def test_run_compile_missing_program_fails( + compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + """A green ninja run that produced no program fails by name.""" + compile_env.run.return_value = _completed() + with caplog.at_level(logging.ERROR): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1 + assert "Build produced no" in caplog.text + compile_env.idedata.assert_not_called() + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None: + with ( + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache), + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1} + ) as load, + ): + assert toolchain.get_idedata() == {"x": 1} + load.assert_called_once_with( + tmp_path / ".pioenvs" / "dev" / "compile_commands.json", + tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME, + CORE.relative_internal_path("idedata", "dev.json"), + launcher=ccache, + ) + + +@pytest.mark.parametrize("platform", ["darwin", "win32"]) +def test_check_analysis_supported_refuses_non_elf(platform: str) -> None: + with ( + patch.object(toolchain.sys, "platform", platform), + pytest.raises(EsphomeError, match=f"the host build on {platform}"), + ): + toolchain.check_analysis_supported() + + +def test_check_analysis_supported_accepts_linux() -> None: + with patch.object(toolchain.sys, "platform", "linux"): + toolchain.check_analysis_supported() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index b6878c33a20..1eeddd80eac 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -34,7 +34,12 @@ HEAVY_MODULES = ( # Everything the storage fast path must keep out of sys.modules; the # existence guard and the leak check must watch the same list. -FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) +FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ( + "esphome.components.esp32", + # A native backend module resolves artifact paths on this path; its + # framework half pulls in the whole package-download stack + "esphome.platformio.registry", +) # Heavy only for modules that must not know about the API transport; # in the existence guard so a rename can't silently no-op its check. @@ -175,6 +180,27 @@ def test_esptool_upload_fast_path_does_not_import_heavy_modules( ) +def test_native_upload_fast_path_does_not_import_heavy_modules( + fixture_path: Path, + probe_env: dict[str, str], +) -> None: + """The native-toolchain serial upload dispatches through a + toolchain-keyed backend table; resolving the flash image must not drag + in the esp32 or esp8266 component package or the validation stack. + """ + leaked = _leaked_from_fixture( + fixture_path, + probe_env, + "native_upload_fast_path.py", + extra=("esphome.components.esp8266",), + ) + assert not leaked, ( + f"native upload_using_esptool pulls in heavy modules: {leaked}. " + "The upload fast path skips validation; importing a platform " + "component package executes its codegen module by design." + ) + + def test_api_client_does_not_import_heavy_modules() -> None: """``esphome.api_client`` is on the logs fast path and must stay light. diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8fb9b7376e3..837643417d5 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -13,7 +13,7 @@ import sys import time from types import SimpleNamespace from typing import Any, Self -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import pytest from pytest import CaptureFixture @@ -42,7 +42,6 @@ from esphome.__main__ import ( command_config_hash, command_dashboard, command_idedata, - command_rename, command_run, command_update_all, command_wizard, @@ -101,7 +100,6 @@ from esphome.const import ( CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, - CONF_SUBSTITUTIONS, CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, @@ -2115,6 +2113,7 @@ def test_upload_program_ota_success( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2153,9 +2152,75 @@ def test_upload_program_ota_encryption_key( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=False, + allow_plaintext_upload=False, ) +def test_upload_program_bare_encryption_block_never_falls_back( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A bare `ota: encryption:` (the api key inherited by final validate, no + option) fails closed against a device that does not offer encryption.""" + from esphome.components.esphome.ota import ota_esphome_final_validate + import esphome.final_validate as fv + + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: key}}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: {}}], + } + token = fv.full_config.set(config) + try: + ota_esphome_final_validate({}) + config = fv.full_config.get() + finally: + fv.full_config.reset(token) + assert config[CONF_OTA][0][CONF_ENCRYPTION] == {CONF_KEY: key} + + with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: + upload_program(config, MockArgs(), ["192.168.1.100"]) + assert run_ota.call_args.args[5] == key + assert run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +def test_upload_program_ota_allow_plaintext_upload( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """The uploader side opt in reaches run_ota without the removed fallback.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: key, "allow_plaintext_upload": True}, + } + ] + } + exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[2] == "pw" + assert mock_run_ota.call_args.args[5] == key + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": True, + } + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2186,6 +2251,7 @@ def test_upload_program_ota_api_key_opportunistic( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=True, + allow_plaintext_upload=False, ) @@ -2214,7 +2280,10 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == {"plaintext_fallback": False} + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2274,6 +2343,7 @@ def test_upload_program_ota_with_file_arg( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2330,6 +2400,7 @@ def test_upload_program_ota_partition_table_with_file_arg( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2393,6 +2464,7 @@ def test_upload_program_ota_partition_table_mqttip( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2582,6 +2654,7 @@ def test_upload_program_ota_bootloader_with_file_arg( OTA_TYPE_UPDATE_BOOTLOADER, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3077,6 +3150,7 @@ def test_upload_program_ota_with_mqtt_resolution( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3133,6 +3207,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -4371,627 +4446,6 @@ def test_command_config_hash( assert output == f"0x{CORE.config_hash:08x}" -def test_command_rename_invalid_characters( - tmp_path: Path, capfd: CaptureFixture[str] -) -> None: - """Test command_rename with invalid characters in name.""" - setup_core(tmp_path=tmp_path) - - # Test with invalid character (space) - args = MockArgs(name="invalid name") - result = command_rename(args, {}) - - assert result == 1 - captured = capfd.readouterr() - assert "invalid character" in captured.out.lower() - - -def test_command_rename_complex_yaml( - tmp_path: Path, capfd: CaptureFixture[str] -) -> None: - """Test command_rename with complex YAML that cannot be renamed.""" - config_file = tmp_path / "test.yaml" - config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - args = MockArgs(name="newname") - result = command_rename(args, {}) - - assert result == 1 - captured = capfd.readouterr() - assert "complex yaml" in captured.out.lower() - - -def test_command_rename_success( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test successful rename of a simple configuration.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s - -wifi: - ssid: "test" - password: "test1234" -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - # Set up CORE.config to avoid ValueError when accessing CORE.address - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - - # Simulate successful validation and upload - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - # Verify new file was created - new_file = tmp_path / "newname.yaml" - assert new_file.exists() - - # Verify old file was removed - assert not config_file.exists() - - # Verify content was updated - content = new_file.read_text() - assert ( - 'name: "newname"' in content - or "name: 'newname'" in content - or "name: newname" in content - ) - - captured = capfd.readouterr() - assert "SUCCESS" in captured.out - - -def test_command_rename_with_substitutions( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename with substitutions in YAML.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -substitutions: - device_name: oldname - -esphome: - name: ${device_name} - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - # Set up CORE.config to avoid ValueError when accessing CORE.address - CORE.config = { - CONF_ESPHOME: {CONF_NAME: "oldname"}, - CONF_SUBSTITUTIONS: {"device_name": "oldname"}, - } - - args = MockArgs(name="newname", dashboard=False) - - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - # Verify substitution was updated - new_file = tmp_path / "newname.yaml" - content = new_file.read_text() - assert 'device_name: "newname"' in content - - -def test_command_rename_validation_failure( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename when validation fails.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - args = MockArgs(name="newname", dashboard=False) - - # First call for validation fails - mock_run_external_process.return_value = 1 - - result = command_rename(args, {}) - - assert result == 1 - - # Verify new file was created but then removed due to failure - new_file = tmp_path / "newname.yaml" - assert not new_file.exists() - - # Verify old file still exists (not removed on failure) - assert config_file.exists() - - captured = capfd.readouterr() - assert "Rename failed" in captured.out - - -def test_command_rename_install_failure_reverts( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename when the install (esphome run) step fails.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - - # First call (config validation) succeeds; second (esphome run) fails. - mock_run_external_process.side_effect = [0, 1] - - result = command_rename(args, {}) - - assert result == 1 - - # New file was unlinked when install failed. - new_file = tmp_path / "newname.yaml" - assert not new_file.exists() - - # Old file is preserved so the device stays reachable under the - # original hostname. - assert config_file.exists() - - -def test_command_rename_target_exists_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when the target filename already exists. - - Without this guard, the rename would overwrite the unrelated - device's YAML and OTA-install our firmware to the wrong device. - """ - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s -""") - target_file = tmp_path / "newname.yaml" - target_file.write_text(""" -esphome: - name: someoneelse - -esp32: - board: nodemcu-32s -""") - target_original = target_file.read_text() - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - # No subprocess work happened — refusal is up-front. - mock_run_external_process.assert_not_called() - # Target file untouched: same content, still on disk. - assert target_file.exists() - assert target_file.read_text() == target_original - # Source file untouched. - assert config_file.exists() - - captured = capfd.readouterr() - assert "already exists" in captured.out - - -def test_command_rename_same_name_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when the new name matches the current name. - - A same-name rename would otherwise re-write the YAML and queue - a redundant compile + install — wasted work the user almost - certainly didn't intend. - """ - config_file = tmp_path / "samename.yaml" - config_file.write_text(""" -esphome: - name: samename - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}} - - args = MockArgs(name="samename", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # File preserved verbatim — no rewrite happened. - assert config_file.exists() - - captured = capfd.readouterr() - assert "already" in captured.out.lower() - - -def test_command_rename_does_not_touch_friendly_name_substring( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - r"""Test rename does not match the ``name:`` substring of ``friendly_name:``. - - Without anchoring the regex at line start, the pattern - ``\s*name:\s+`` could match the trailing ``name:`` - substring inside ``friendly_name: ``. The rewrite would - flip both lines to the new name, leaving the user with a - silently corrupted ``friendly_name``. - """ - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - friendly_name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "newname.yaml" - content = new_file.read_text() - # esphome.name swapped. - assert 'name: "newname"' in content - # friendly_name kept verbatim. - assert "friendly_name: oldname" in content - - -def test_command_rename_does_not_match_old_name_as_value_prefix( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - r"""Test rename does not match ``old_name`` as a prefix of a longer value. - - With ``old_name = kitchen`` the value ``kitchen2`` (a sensor - or wifi entry) would otherwise match the unanchored - ``["']?kitchen["']?`` pattern at the prefix and get - rewritten to the new name. The end-of-value lookahead keeps - the match restricted to whole tokens. - """ - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: kitchen - -esp32: - board: nodemcu-32s - -wifi: - ap: - ssid: kitchen2 -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "garage.yaml" - content = new_file.read_text() - assert 'name: "garage"' in content - # The wifi ssid value is unrelated and stays intact. - assert "ssid: kitchen2" in content - - -def test_command_rename_same_resolved_name_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when ``new_name`` matches the resolved device name. - - The path-equality check only catches the case where the - config filename matches the device name. For a config whose - filename and ``esphome.name`` differ (here ``weird-file.yaml`` - holds ``esphome.name: kitchen``), running - ``esphome rename weird-file.yaml kitchen`` would otherwise - fall through to the rewrite + install: the YAML's name stays - ``kitchen``, the file is renamed to ``kitchen.yaml``, and the - device gets a redundant flash. Refuse up-front so the - "already the device's name" message matches reality. - """ - config_file = tmp_path / "weird-file.yaml" - config_file.write_text(""" -esphome: - name: kitchen - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="kitchen", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # Source file untouched, no derived target written. - assert config_file.exists() - assert not (tmp_path / "kitchen.yaml").exists() - - captured = capfd.readouterr() - assert "already" in captured.out.lower() - - -def test_command_rename_target_path_equals_source_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when the new path resolves to the source file. - - Reachable only when the YAML's filename and ``esphome.name`` - disagree — here ``kitchen.yaml`` holds ``esphome.name: garage`` - and the user runs ``esphome rename kitchen.yaml kitchen``. The - name-equality check above passes (``garage != kitchen``), but - ``/kitchen.yaml`` resolves to the source file - itself, so the rewrite would clobber the source mid-rename. - Refuse rather than silently overwriting. - """ - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: garage - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}} - - args = MockArgs(name="kitchen", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # Source file still present and unmodified. - assert config_file.exists() - assert "name: garage" in config_file.read_text() - - captured = capfd.readouterr() - assert "already" in captured.out.lower() - - -def test_command_rename_does_not_touch_lookalike_name_in_other_blocks( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename only swaps the esphome.name line. - - A device whose name happens to match a sensor's / output's - ``name:`` value must not have those other names rewritten — - they're independent. Without an anchor for the esphome block - a naive regex would clobber every line whose value matches. - """ - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: kitchen - -esp32: - board: nodemcu-32s - -sensor: - - platform: template - name: kitchen - lambda: 'return 0;' -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - new_file = tmp_path / "garage.yaml" - content = new_file.read_text() - # esphome.name renamed. - assert 'name: "garage"' in content - # Sensor's name is the user's entity name — must not be touched. - assert " name: kitchen\n" in content - - -def test_command_rename_preserves_trailing_comment( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename preserves a trailing ``# comment`` on the name line.""" - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: kitchen # primary device - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - new_file = tmp_path / "garage.yaml" - content = new_file.read_text() - assert "# primary device" in content - - -def test_command_rename_handles_double_quoted_value( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename matches when the existing value is double-quoted.""" - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: "kitchen" - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "garage.yaml" - assert 'name: "garage"' in new_file.read_text() - - -def test_command_rename_handles_single_quoted_value( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename matches when the existing value is single-quoted.""" - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: 'kitchen' - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "garage.yaml" - assert 'name: "garage"' in new_file.read_text() - - -def test_command_rename_too_many_substitution_matches_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when ``${var}`` resolves to multiple matches. - - When ``esphome.name: ${device_name}`` and the substitution - definition ``device_name: foo`` appears more than once in the - YAML (e.g. inside multiple included blocks), the regex rewrite - can't tell which one to flip. Rather than silently picking one - or rewriting both, the command refuses. - """ - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -substitutions: - device_name: oldname - -esphome: - name: ${device_name} - -# A copy-pasted block that re-declares the substitution at the -# same indent level - happens when users splice in a packaged -# fragment without renaming the variable. -example: - device_name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = { - CONF_ESPHOME: {CONF_NAME: "oldname"}, - CONF_SUBSTITUTIONS: {"device_name": "oldname"}, - } - - args = MockArgs(name="newname", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # File untouched. - assert config_file.exists() - assert "device_name: oldname" in config_file.read_text() - - captured = capfd.readouterr() - assert "Too many matches" in captured.out - - def test_command_update_all_path_string_conversion( tmp_path: Path, mock_run_external_process: Mock, @@ -5306,6 +4760,7 @@ def test_upload_program_ota_static_ip_with_mqttip( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5357,6 +4812,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5541,6 +4997,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5919,6 +5376,26 @@ def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None: mock_check.assert_called_once() +def test_compile_program_esp8266_native_skips_rosetta_check(tmp_path: Path) -> None: + """The native toolchain has an arm64 macOS build, so nothing is probed.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + + with ( + patch("esphome.components.esp8266.check_rosetta") as mock_check, + patch( + "esphome.components.esp8266.run_compile", + side_effect=EsphomeError("compile reached"), + ), + pytest.raises(EsphomeError, match="compile reached"), + ): + compile_program(MockArgs(), config) + + mock_check.assert_not_called() + + def test_compile_program_skips_rosetta_check_on_other_platforms( tmp_path: Path, mock_compile_build_info_run_compile: Mock, @@ -7132,7 +6609,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" - setup_core() + setup_core(platform=PLATFORM_ESP32) CORE.toolchain = Toolchain.ESP_IDF data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} @@ -7146,7 +6623,7 @@ def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: def test_command_idedata_esp_idf_no_build_errors() -> None: """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" - setup_core() + setup_core(platform=PLATFORM_ESP32) CORE.toolchain = Toolchain.ESP_IDF with patch("esphome.espidf.toolchain.get_idedata", return_value=None): @@ -7320,6 +6797,203 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails( assert not caplog.text +def test_upload_using_esptool_arduino_toolchain( + tmp_path: Path, + mock_run_external_command_main: Mock, +) -> None: + """The native ESP8266 Arduino toolchain flashes its factory image at + 0x0, resolved from the toolchain-keyed backend table (deliberately not + the platform hook: that import would break the upload fast path).""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = Toolchain.ARDUINO + from esphome.arduino8266 import toolchain as native + + factory = native.get_factory_firmware_path() + factory.parent.mkdir(parents=True, exist_ok=True) + factory.touch() + + config = {CONF_ESPHOME: {"platformio_options": {}}} + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + cmd_list = list(mock_run_external_command_main.call_args[0][1:]) + firmware_offset_idx = cmd_list.index("write-flash") + 4 + assert cmd_list[firmware_offset_idx] == "0x0" + assert cmd_list[firmware_offset_idx + 1] == str(factory) + + +@pytest.mark.parametrize( + ("toolchain", "pio_project_written"), + [ + # The native toolchain generates its project at compile time, so + # write_cpp_file must not write a platformio.ini; the default + # toolchain writes the PlatformIO project files. + (Toolchain.ARDUINO, False), + (None, True), + ], +) +def test_write_cpp_file_project_generation_follows_toolchain( + tmp_path: Path, toolchain: Toolchain | None, pio_project_written: bool +) -> None: + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = toolchain + + with ( + patch("esphome.writer.write_cpp") as mock_write_cpp, + patch("esphome.build_gen.platformio.write_project") as mock_pio_project, + patch.object( + type(CORE), "cpp_main_section", new_callable=PropertyMock + ) as mock_section, + ): + mock_section.return_value = "" + assert main.write_cpp_file() == 0 + + mock_write_cpp.assert_called_once() + assert mock_pio_project.called is pio_project_written + + +def test_command_idedata_arduino_prints_json( + tmp_path: Path, capsys: CaptureFixture +) -> None: + """Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path) + CORE.toolchain = Toolchain.ARDUINO + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch( + "esphome.arduino8266.toolchain.get_idedata", return_value=data + ) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: + """A missing native build (no idedata) returns an error, not a crash.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path) + CORE.toolchain = Toolchain.ARDUINO + + with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 + + +@pytest.mark.parametrize( + ("platform", "toolchain", "module"), + [ + (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), + (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + # No native build backend, but its binutils and ELF are known + (PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"), + ], +) +def test_command_analyze_memory_native_toolchains( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, + platform: str, + toolchain: Toolchain, + module: str, +) -> None: + """analyze-memory uses the native toolchain's binutils instead of + falling into the PlatformIO branch.""" + setup_core(platform=platform, tmp_path=tmp_path, name="test_device") + CORE.toolchain = toolchain + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + # The tools must exist: a missing binutils now fails by name instead of + # silently falling back to host tools + objdump = tmp_path / "objdump" + readelf = tmp_path / "readelf" + objdump.write_text("") + readelf.write_text("") + # The ELF must exist too: the analyzer swallows tool failures, so a + # missing image would report zeroes with exit 0 + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_text("") + with ( + patch(f"{module}.get_objdump_path", return_value=objdump), + patch(f"{module}.get_readelf_path", return_value=readelf), + patch(f"{module}.get_elf_path", return_value=firmware_elf), + ): + result = command_analyze_memory(MockArgs(), config) + + assert result == 0 + mock_memory_analyzer_cli.assert_called_once_with( + str(firmware_elf), + str(objdump), + str(readelf), + set(), + idedata=None, + ) + + +def test_command_analyze_memory_native_missing_elf_fails( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing firmware.elf fails by name instead of an exit-0 zeroed + report.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + objdump = tmp_path / "objdump" + readelf = tmp_path / "readelf" + objdump.write_text("") + readelf.write_text("") + module = "esphome.arduino8266.toolchain" + with ( + patch(f"{module}.get_objdump_path", return_value=objdump), + patch(f"{module}.get_readelf_path", return_value=readelf), + patch(f"{module}.get_elf_path", return_value=tmp_path / "missing.elf"), + ): + result = command_analyze_memory(MockArgs(), config) + + assert result == 1 + assert "compile the configuration first" in caplog.text + mock_memory_analyzer_cli.assert_not_called() + + +def test_command_analyze_memory_missing_binutils_fails_by_name( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A truncated toolchain install fails naming the missing tool instead + of silently analyzing with host binutils.""" + setup_core(platform="esp8266", tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + module = "esphome.arduino8266.toolchain" + with ( + patch(f"{module}.get_objdump_path", return_value=tmp_path / "missing-objdump"), + patch(f"{module}.get_readelf_path", return_value=tmp_path / "readelf"), + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + ): + assert command_analyze_memory(MockArgs(), config) == 1 + assert "missing-objdump" in caplog.text + assert "toolchain install may be incomplete" in caplog.text + + +def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None: + """A non-native, non-platformio toolchain errors out cleanly.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + CORE.toolchain = Toolchain.SDK_NRF + + assert command_idedata(MagicMock(), CORE.config) == 1 + + @pytest.mark.parametrize( "error", [ @@ -7396,6 +7070,54 @@ def test_compile_program_espidf_idedata_none_warns( assert "No idedata was generated" in caplog.text +def test_native_toolchain_table_serves_every_native_toolchain() -> None: + """Every member of NATIVE_TOOLCHAINS has a backend entry; a gap would + surface as a targeted EsphomeError on the one affected config, and this + pin keeps the table from drifting when a toolchain is added.""" + from esphome.build_helpers.native import NATIVE_TOOLCHAIN_MODULES + from esphome.const import NATIVE_TOOLCHAINS + + assert {tc for _, tc in NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS) + + +def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None: + """A native toolchain missing from the backend table is a bug and must + fail, not silently degrade to the PlatformIO path.""" + from esphome.build_helpers import native + + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + with ( + patch.dict(native.NATIVE_TOOLCHAIN_MODULES, clear=True), + pytest.raises(EsphomeError, match="no native build backend"), + ): + native.native_backend() + + +def test_command_analyze_memory_unsupported_toolchain( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook-less non-PlatformIO toolchain is refused by name, never routed + into the PlatformIO branch.""" + setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.SDK_NRF + mock_write_cpp.return_value = 0 + mock_compile_program.return_value = 0 + + # Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for + # one that does not + with patch.dict( + "esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True + ): + result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) + + assert result == 1 + assert "analyze-memory is not supported" in caplog.text + + def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None: """An explicit --toolchain must run the per-platform validators, so the upload/logs fast path becomes a cache miss.""" @@ -7411,6 +7133,29 @@ def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None: mock_read.assert_called_once() +def test_upload_using_esptool_native_missing_firmware_raises( + tmp_path: Path, +) -> None: + """A stale or absent firmware.bin fails by name instead of flashing air.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(EsphomeError, match="compile the configuration first"): + upload_using_esptool( + {CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", None, None + ) + + +def test_compile_program_unclaimed_native_toolchain_raises( + tmp_path: Path, +) -> None: + """A resolved native toolchain no platform backend claims must fail, + never fall through to the PlatformIO project path.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO # esp32 has no arduino-native backend + with pytest.raises(EsphomeError, match="no platform backend claimed"): + compile_program(MockArgs(), {}) + + def test_cli_toolchain_still_refreshes_the_validated_config_cache( tmp_path: Path, ) -> None: @@ -7457,52 +7202,214 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: assert second.index("a: 2") < second.index("z: 1") -def test_host_program_path_platformio_toolchain() -> None: - """Host + PlatformIO toolchain reads the memoized idedata path.""" - setup_core(platform=PLATFORM_HOST) - idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program") - with patch( - "esphome.platformio.toolchain.get_idedata", return_value=idedata - ) as mock_get: - assert main._host_program_path({}) == "/build/x/.pioenvs/x/program" - mock_get.assert_called_once_with({}) - - -def test_host_program_path_esp_idf_toolchain() -> None: - """Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF.""" - setup_core(platform=PLATFORM_HOST) - CORE.toolchain = Toolchain.ESP_IDF - with patch( - "esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf") - ): - assert main._host_program_path({}) == str(Path("/b/app.elf")) - - def test_command_compile_host_logs_program_path( + tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """command_compile on host logs the compiled program path.""" - setup_core(platform=PLATFORM_HOST) + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") with ( patch.object(main, "write_cpp", return_value=0), patch.object(main, "compile_program", return_value=0), - patch.object(main, "_host_program_path", return_value="/b/program"), caplog.at_level(logging.INFO), ): assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0 - assert "Successfully compiled program to path '/b/program'" in caplog.text + assert f"Successfully compiled program to path '{CORE.firmware_bin}'" in caplog.text -def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None: +def test_command_run_host_executes_program( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: """command_run on host logs and executes the compiled program directly.""" - setup_core(platform=PLATFORM_HOST) + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") with ( patch.object(main, "write_cpp", return_value=0), patch.object(main, "compile_program", return_value=0), - patch.object(main, "_host_program_path", return_value="/b/program"), patch.object(main, "run_external_process", return_value=0) as mock_run, caplog.at_level(logging.INFO), ): assert main.command_run(SimpleNamespace(), {}) == 0 - mock_run.assert_called_with("/b/program") - assert "Running program from path '/b/program'" in caplog.text + program = str(CORE.firmware_bin) + mock_run.assert_called_with(program) + assert f"Running program from path '{program}'" in caplog.text + + +def test_write_cpp_file_project_generation_follows_host_toolchain() -> None: + """Only PlatformIO gets a platformio.ini; ESP-IDF writes its CMake project + here, and the other native builds generate theirs at compile time.""" + setup_core(platform=PLATFORM_HOST) + with ( + patch("esphome.writer.write_cpp"), + patch("esphome.build_gen.platformio.write_project") as mock_pio, + patch("esphome.build_gen.espidf.write_project") as mock_idf, + ): + CORE.toolchain = Toolchain.HOST + assert main.write_cpp_file() == 0 + mock_pio.assert_not_called() + mock_idf.assert_not_called() + CORE.toolchain = Toolchain.PLATFORMIO + assert main.write_cpp_file() == 0 + mock_pio.assert_called_once() + CORE.toolchain = Toolchain.ESP_IDF + assert main.write_cpp_file() == 0 + mock_idf.assert_called_once() + + +def test_compile_program_host_uses_the_platform_hook() -> None: + """The host component's run_compile claims the build.""" + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.components.host.run_compile", return_value=True) as hook, + patch("esphome.__main__._check_and_emit_build_info") as build_info, + ): + assert compile_program(MagicMock(), {}) == 0 + hook.assert_called_once() + build_info.assert_called_once() + + +def test_compile_program_native_toolchain_needs_a_backend() -> None: + """A native toolchain no hook claims must not fall through to PlatformIO.""" + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.platformio.toolchain.run_compile") as mock_pio, + # A platform package without a run_compile hook + patch.dict(sys.modules, {"esphome.components.host": SimpleNamespace()}), + pytest.raises(EsphomeError, match="no platform backend claimed the build"), + ): + compile_program(MagicMock(), {}) + mock_pio.assert_not_called() + + +def test_native_backend_resolves_host() -> None: + from esphome.build_helpers.native import native_backend + from esphome.host import toolchain as host_toolchain + + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.PLATFORMIO + assert native_backend() is None + CORE.toolchain = Toolchain.HOST + assert native_backend() is host_toolchain + # A native toolchain without a backend for the platform must not degrade + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises( + EsphomeError, match="has no native build backend module for platform host" + ): + native_backend() + + +def test_command_idedata_host_prints_json(capsys: CaptureFixture) -> None: + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + data = {"cxx_path": "g++", "prog_path": "/build/program"} + with patch("esphome.host.toolchain.get_idedata", return_value=data) as mock_get: + assert command_idedata(MagicMock(), CORE.config) == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def _native_host_tools(tmp_path: Path) -> tuple[Path, Path, Path]: + objdump, readelf, elf = (tmp_path / n for n in ("objdump", "readelf", "program")) + for tool in (objdump, readelf, elf): + tool.write_text("") + return objdump, readelf, elf + + +def test_command_analyze_memory_native_toolchain( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, +) -> None: + """A native backend supplies its own binutils and ELF, with no idedata.""" + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + config = {CONF_ESPHOME: {CONF_NAME: "dev"}} + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + ): + assert command_analyze_memory(MockArgs(), config) == 0 + mock_memory_analyzer_cli.assert_called_once_with( + str(elf), str(objdump), str(readelf), set(), idedata=None + ) + mock_ram_strings_analyzer.assert_called_once_with( + str(elf), objdump_path=str(objdump), platform="host" + ) + assert "Mock Memory Report" in capfd.readouterr().out + + +def test_command_analyze_memory_native_missing_tool_fails( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + readelf.unlink() + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + caplog.at_level(logging.ERROR), + ): + assert command_analyze_memory(MockArgs(), {}) == 1 + assert f"{readelf} is missing; the toolchain install may be incomplete" in ( + caplog.text + ) + + +def test_command_analyze_memory_host_missing_elf_fails( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + elf.unlink() + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + caplog.at_level(logging.ERROR), + ): + assert command_analyze_memory(MockArgs(), {}) == 1 + assert f"{elf} is missing; compile the configuration first" in caplog.text + + +def test_command_analyze_memory_host_refuses_before_compiling( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + """A machine whose host program is not ELF fails before the compile.""" + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.host.toolchain.sys.platform", "darwin"), + pytest.raises(EsphomeError, match="analyze-memory reads ELF files"), + ): + command_analyze_memory(MockArgs(), {}) + mock_write_cpp.assert_not_called() + mock_compile_program.assert_not_called() + + +def test_command_rename_is_dispatched_to_the_cli_module() -> None: + """__main__ keeps a thin wrapper and imports the command when it runs.""" + args = MockArgs(name="newname") + with patch("esphome.cli.rename.command_rename", return_value=7) as run: + assert main.command_rename(args, {}) == 7 + run.assert_called_once_with(args, {}) diff --git a/tests/unit_tests/test_nrf52_build.py b/tests/unit_tests/test_nrf52_build.py new file mode 100644 index 00000000000..868bc9549f2 --- /dev/null +++ b/tests/unit_tests/test_nrf52_build.py @@ -0,0 +1,23 @@ +"""Tests for the nRF52 native west build command.""" + +from pathlib import Path + +from esphome.components.nrf52 import _west_build_command + + +def test_west_build_sets_the_cmake_build_type() -> None: + """The picolibc module used to force MinSizeRel, and with it the -DNDEBUG + that keeps libc assert() out of the image; the build sets it itself now.""" + python = Path("/penv/python") + cmd = _west_build_command( + python, + "adafruit_feather_nrf52840", + Path("/build/pio"), + Path("/build/zephyr"), + ) + + # str(), not a literal: the separator differs on Windows + assert cmd[:4] == [str(python), "-m", "west", "build"] + assert "-b" in cmd and "adafruit_feather_nrf52840" in cmd + # CMake arguments come after west's own + assert cmd[cmd.index("--") + 1 :] == ["-DCMAKE_BUILD_TYPE=MinSizeRel"] diff --git a/tests/unit_tests/test_nrf52_config.py b/tests/unit_tests/test_nrf52_config.py new file mode 100644 index 00000000000..4d9d281ebfe --- /dev/null +++ b/tests/unit_tests/test_nrf52_config.py @@ -0,0 +1,12 @@ +"""Tests for the nRF52 configuration validation.""" + +import pytest + +from esphome.components.nrf52 import _detect_bootloader +import esphome.config_validation as cv + + +def test_detect_bootloader_reports_a_missing_board() -> None: + """The bootloader check runs before the schema, so it reports the missing key.""" + with pytest.raises(cv.Invalid, match="'board' is a required option"): + _detect_bootloader({}) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index b78a94a2e7c..d6b27bdbef2 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,11 +1,12 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import errno import hashlib import os from pathlib import Path import sys from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import ANY, call, patch import platformdirs import pytest @@ -14,16 +15,20 @@ from esphome.components.nrf52 import _resolve_toolchain from esphome.components.nrf52.framework import ( _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, + DEFAULT_WEST_PROJECTS, TOOLCHAIN_VERSION, _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, _needs_venv_rebuild, + _wanted_west_projects, check_and_install, get_build_env, get_sdk_nrf_tools_path, + include_west_project, setup_platformio_python_env, ) +from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain @@ -76,6 +81,8 @@ def test_get_toolchain_platform_info( # --------------------------------------------------------------------------- _TEST_SDK_VERSION = "2.9.0" +# The filter that keeps only DEFAULT_WEST_PROJECTS +_DEFAULTS_FILTER = "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr" @pytest.fixture @@ -129,6 +136,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_west_initialized(framework: Path) -> None: + """What a finished ``west init`` leaves behind.""" + (framework / ".west").mkdir() + (framework / ".west" / "config").touch() + + def _touch_penv_python(penv: Path) -> None: """Create the interpreter file so the rebuild gate sees a live venv.""" python = get_python_env_executable_path(penv, "python") @@ -136,6 +149,33 @@ def _touch_penv_python(penv: Path) -> None: python.touch() +def _subcommand(cmd: list[str]) -> str: + tool = "west" if "west" in cmd else "pip" + return cmd[cmd.index(tool) + 1] + + +def _subcommands(run_command_ok) -> list[str]: + """The west or pip subcommand of each command run, in order.""" + return [_subcommand(c.args[0]) for c in run_command_ok.call_args_list] + + +def _project_filter(run_command_ok) -> str: + """The manifest.project-filter value of the last ``west config`` run.""" + for west_call in reversed(run_command_ok.call_args_list): + cmd = west_call.args[0] + if "manifest.project-filter" in cmd: + return cmd[-1] + raise AssertionError("no west config command ran") + + +def _mark_installed(dirs: SimpleNamespace) -> None: + """Every install step finished: venv, zephyr requirements, SDK, toolchain.""" + _mark_venv_ready(dirs.python_env) + (dirs.python_env / ".zephyr_reqs_ready").touch() + (dirs.framework / ".ready").touch() + (dirs.toolchain / ".ready").touch() + + def _mark_venv_ready(python_env: Path) -> None: """Write the venv sentinel with the current requirements hash and a present interpreter so the rebuild gate passes.""" @@ -151,10 +191,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - _mark_venv_ready(nrf52_dirs.python_env) - (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() - (nrf52_dirs.framework / ".ready").touch() - (nrf52_dirs.toolchain / ".ready").touch() + _mark_installed(nrf52_dirs) check_and_install() @@ -189,8 +226,14 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install requirements, west init, west update, pip install zephyr reqs - assert mock_nrf52_ops.run_command_ok.call_count == 4 + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "install", # requirements + "init", + "config", + "update", + "list", + "install", # zephyr requirements + ] # minimal SDK + per-arch toolchain assert mock_nrf52_ops.download_from_mirrors.call_count == 2 assert mock_nrf52_ops.archive_extract_all.call_count == 2 @@ -210,8 +253,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init, west update, pip install zephyr reqs - assert mock_nrf52_ops.run_command_ok.call_count == 3 + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "init", + "config", + "update", + "list", + "install", + ] # minimal SDK + per-arch toolchain assert mock_nrf52_ops.download_from_mirrors.call_count == 2 @@ -243,13 +291,104 @@ class TestCheckAndInstall: check_and_install() - init_cmd, update_cmd = ( - call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2] + init, _, update = mock_nrf52_ops.run_command_ok.call_args_list[:3] + assert "-o=--depth=1" in init.args[0] + assert "--fetch-opt=--depth=1" in update.args[0] + # Streamed, so the long clone's progress reaches the log + assert init.kwargs["stream_output"] is True + assert update.kwargs["stream_output"] is True + + def test_interrupted_download_resumes( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A workspace left by a cut-short download is updated in place, not + wiped and cloned again.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + + # A marker left by an earlier failed resume + (nrf52_dirs.framework / ".resume_failed").touch() + + check_and_install() + + assert ( + call(nrf52_dirs.framework, msg=ANY) + not in mock_nrf52_ops.rmdir.call_args_list ) - assert "init" in init_cmd - assert "-o=--depth=1" in init_cmd - assert "update" in update_cmd - assert "--fetch-opt=--depth=1" in update_cmd + assert not (nrf52_dirs.framework / ".resume_failed").exists() + # west update in the workspace (no init), then pip install zephyr reqs + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "config", + "update", + "list", + "install", + ] + update = mock_nrf52_ops.run_command_ok.call_args_list[1] + assert update.kwargs["cwd"] == nrf52_dirs.framework + assert (nrf52_dirs.framework / ".ready").exists() + + def test_failed_resume_keeps_the_download_once( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A first failed resume keeps what was fetched (the network likely + dropped again) and is retried on the next build.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + # config succeeds, the resumed update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, False] + + with pytest.raises(EsphomeError, match="Can't resume"): + check_and_install() + + assert ( + call(nrf52_dirs.framework, msg=ANY) + not in mock_nrf52_ops.rmdir.call_args_list + ) + assert (nrf52_dirs.framework / ".resume_failed").exists() + + def test_cut_short_init_starts_over( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A ``.west`` without its config (init cut short) clones clean.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".west").mkdir() + + check_and_install() + + mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) + first = mock_nrf52_ops.run_command_ok.call_args_list[0] + assert "init" in first.args[0] + + def test_second_failed_resume_starts_over( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A resume failing twice in a row wipes the workspace and clones clean.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".resume_failed").touch() + # resumed update fails; the clean clone and zephyr reqs succeed + mock_nrf52_ops.run_command_ok.side_effect = [True, False, *[True] * 5] + + check_and_install() + + mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "config", + "update", + "init", + "config", + "update", + "list", + "install", + ] def test_requirements_install_failure_raises( self, @@ -281,12 +420,372 @@ class TestCheckAndInstall: ) -> None: """Failing west update raises EsphomeError.""" _mark_venv_ready(nrf52_dirs.python_env) - # init succeeds, update fails - mock_nrf52_ops.run_command_ok.side_effect = [True, False] + # init and config succeed, update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False] with pytest.raises(EsphomeError, match="Can't update"): check_and_install() + def test_fresh_install_fetches_only_default_projects( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A fresh clone leaves every west project out except the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + + check_and_install() + + assert _project_filter(mock_nrf52_ops.run_command_ok) == ( + "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr" + ) + stamp = nrf52_dirs.framework / ".west_projects" + assert stamp.read_text(encoding="utf-8").split() == sorted( + DEFAULT_WEST_PROJECTS + ) + + def test_included_project_joins_the_filter( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A project a component includes is fetched with the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + include_west_project("oberon-psa-crypto") + + check_and_install() + + assert "+oberon-psa-crypto" in _project_filter( + mock_nrf52_ops.run_command_ok + ).split(",") + + def test_installed_sdk_fetches_a_newly_needed_project( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A finished install gains a project another config left out, keeping + what it already has.""" + _mark_installed(nrf52_dirs) + (nrf52_dirs.framework / ".west_projects").write_text( + "cmsis\nhal_nordic\nnrfxlib\ntinycrypt\nzephyr", encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + # The names are checked before anything is fetched + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + wanted = "-.*,+cmsis,+hal_nordic,+nrfxlib,+openthread,+tinycrypt,+zephyr" + assert _project_filter(mock_nrf52_ops.run_command_ok) == wanted + mock_nrf52_ops.rmdir.assert_not_called() + + @pytest.mark.parametrize( + "stamp", + [ + pytest.param(None, id="install_from_before_the_filter"), + pytest.param( + "cmsis\nhal_nordic\nnrfxlib\nopenthread\nzephyr", + id="project_already_fetched", + ), + ], + ) + def test_installed_sdk_with_the_project_fetches_nothing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + stamp: str | None, + ) -> None: + """No fetch when the install already has every wanted project; an + install without the stamp (west config, no filter) has them all. The + names are still checked, which only reads the manifest.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + if stamp is not None: + (nrf52_dirs.framework / ".west_projects").write_text( + stamp, encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + + def test_sysbuild_fetches_mcuboot( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Sysbuild always builds the MCUboot image, so it needs the project.""" + _mark_venv_ready(nrf52_dirs.python_env) + CORE.data[KEY_ZEPHYR] = {KEY_SYSBUILD: True} + + check_and_install() + + assert "+mcuboot" in _project_filter(mock_nrf52_ops.run_command_ok).split(",") + + @pytest.mark.parametrize( + ("sdk_version", "has_cmsis_6"), + [("2.9.2", False), ("3.1.0", True), ("3.2.0", True)], + ) + def test_sdk_3_1_and_later_want_cmsis_6( + self, setup_core: Path, sdk_version: str, has_cmsis_6: bool + ) -> None: + """Zephyr 4.1 moved the Cortex-M core headers to the cmsis_6 module.""" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(sdk_version)} + + assert ("cmsis_6" in _wanted_west_projects()) is has_cmsis_6 + + def test_default_projects_never_read_the_stamp( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A build wanting only the defaults has them on any install; an + unreadable stamp shows the check never looked.""" + _mark_installed(nrf52_dirs) + (nrf52_dirs.framework / ".west_projects").mkdir() + + check_and_install() + + mock_nrf52_ops.run_command_ok.assert_not_called() + + def test_failed_project_fetch_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A failed fetch of a newly needed project raises, keeps the stamp and + puts the workspace filter back to the stamp's projects and the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + stamp = nrf52_dirs.framework / ".west_projects" + stamp.write_text("zephyr", encoding="utf-8") + include_west_project("openthread") + # list and config succeed, update fails, the restoring config succeeds + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert stamp.read_text(encoding="utf-8") == "zephyr" + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + "config", + ] + # The defaults always stay in the restored filter + assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER + + def test_failed_fetch_with_a_lost_stamp_keeps_the_defaults_active( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """An unknown installed set must not leave a filter with every module off.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".west" / "config").write_text( + "[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8" + ) + include_west_project("openthread") + # list and config succeed, update fails, the restoring config succeeds + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER + + def test_failed_filter_restore_is_logged( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """When the filter can't be put back after a failed fetch, the user is told.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8") + include_west_project("openthread") + # list and config succeed, update fails, the restoring config fails too + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, False] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert "Couldn't put the nRF Connect SDK" in caplog.text + + def test_lost_stamp_on_a_filtered_install_fetches_again( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A workspace with a project filter but no stamp is a filtered install + whose record was lost, so the wanted projects are fetched, not assumed.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".west" / "config").write_text( + "[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + + def test_install_waits_for_another_build_holding_the_lock( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A held lock is waited on, never mistaken for a filesystem that + cannot lock (filelock's Timeout is an OSError as well).""" + from filelock import Timeout + + _mark_installed(nrf52_dirs) + + with ( + caplog.at_level("INFO"), + patch("filelock.FileLock") as file_lock, + ): + file_lock.return_value.acquire.side_effect = [Timeout("install.lock"), None] + check_and_install() + + assert file_lock.return_value.acquire.call_count == 2 + assert "Waiting for another build" in caplog.text + assert "continuing without a lock" not in caplog.text + + def test_install_from_before_the_filter_still_checks_the_names( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """An install with every project fetches nothing, but an unknown name + is still rejected so a mistake shows on every install alike.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + + mock_nrf52_ops.run_command_ok.reset_mock() + mock_nrf52_ops.run_command_ok.return_value = False + include_west_project("no_such_project") + with pytest.raises(EsphomeError, match="west list failed"): + check_and_install() + + def test_install_lock_is_per_sdk_version( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Installs of different SDK versions do not wait on each other; the + toolchain they share is only locked while it is missing.""" + _mark_installed(nrf52_dirs) + + with patch("filelock.FileLock") as file_lock: + check_and_install() + + lock_files = [Path(c.args[0]).name for c in file_lock.call_args_list] + assert lock_files == [f"sdk-v{_TEST_SDK_VERSION}.lock"] + + def test_install_runs_unlocked_where_the_filesystem_cannot_lock( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """No soft-lock fallback: its marker outlives a killed build and would + hang every later one, so the install goes ahead without a lock.""" + _mark_installed(nrf52_dirs) + + with patch("filelock.FileLock") as file_lock: + file_lock.return_value.acquire.side_effect = OSError( + errno.ENOSYS, "Function not implemented" + ) + check_and_install() + + assert file_lock.call_args.kwargs == {"fallback_to_soft": False} + assert "continuing without a lock" in caplog.text + + def test_unknown_project_raises_before_anything_is_written( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A filter naming a project the manifest lacks fetches nothing, so + the names are checked once the update resolved the manifest, and an + unknown one is never recorded as installed.""" + _mark_venv_ready(nrf52_dirs.python_env) + include_west_project("no_such_project") + # init, config and update succeed, list fails + mock_nrf52_ops.run_command_ok.side_effect = [True, True, True, False] + + with pytest.raises(EsphomeError, match="west list failed .*no_such_project"): + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "init", + "config", + "update", + "list", + ] + assert "no_such_project" in mock_nrf52_ops.run_command_ok.call_args.args[0] + assert not (nrf52_dirs.framework / ".west_projects").exists() + + def test_unknown_project_on_an_installed_sdk_fetches_nothing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """On a finished install the names are checked first, so an unknown one + costs no fetch and leaves the workspace as it was.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8") + include_west_project("no_such_project") + # list fails before anything is fetched or changed + mock_nrf52_ops.run_command_ok.side_effect = [False] + + with pytest.raises(EsphomeError, match="west list failed"): + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + assert (nrf52_dirs.framework / ".west_projects").read_text( + encoding="utf-8" + ) == "zephyr" + + def test_missing_stamp_and_west_config_fetches_again( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """With neither the stamp nor .west/config left, nothing says what the + install has, so the wanted projects are fetched rather than assumed.""" + _mark_installed(nrf52_dirs) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + def test_toolchain_download_passes_platform_substitutions( self, nrf52_dirs: SimpleNamespace, diff --git a/tests/unit_tests/test_nrf52_toolchain.py b/tests/unit_tests/test_nrf52_toolchain.py new file mode 100644 index 00000000000..ee8f6eaf499 --- /dev/null +++ b/tests/unit_tests/test_nrf52_toolchain.py @@ -0,0 +1,54 @@ +"""Tests for the nRF52 sdk-nrf memory analysis hooks.""" + +import os +from pathlib import Path + +import pytest + +from esphome.components.nrf52 import toolchain +from esphome.components.nrf52.framework import TOOLCHAIN_VERSION, get_sdk_nrf_tools_path +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +@pytest.fixture +def nrf52_build(tmp_path: Path) -> Path: + CORE.name = "test_device" + CORE.build_path = tmp_path / "build" / "test_device" + return CORE.build_path / ".pioenvs" / "test_device" / "zephyr" + + +def _set_sdk_version(version: cv.Version) -> None: + CORE.data.setdefault(KEY_CORE, {})[KEY_FRAMEWORK_VERSION] = version + + +def test_binutils_come_from_the_pinned_zephyr_sdk() -> None: + bin_path = ( + get_sdk_nrf_tools_path() + / "toolchains" + / TOOLCHAIN_VERSION + / "arm-zephyr-eabi" + / "bin" + ) + # Windows hosts get the .exe binaries (CI covers both) + suffix = ".exe" if os.name == "nt" else "" + assert toolchain.get_objdump_path() == bin_path / f"arm-zephyr-eabi-objdump{suffix}" + assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}" + + +def test_elf_uses_the_nested_layout_from_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 9, 2)) + # A flat image left by an older SDK must not be picked over the current + # layout, so the choice follows the version rather than what exists + (nrf52_build / "zephyr.elf").parent.mkdir(parents=True) + (nrf52_build / "zephyr.elf").write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr" / "zephyr.elf" + + +def test_elf_uses_the_flat_layout_before_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 6, 1)) + nested = nrf52_build / "zephyr" / "zephyr.elf" + nested.parent.mkdir(parents=True) + nested.write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr.elf" diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 3bae39b3c1a..658c7211ab4 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -7,6 +7,7 @@ exercised in their own test modules).""" import json import logging from pathlib import Path +from unittest.mock import Mock import pytest @@ -228,6 +229,38 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): _resolve_registry_version("owner", "pkg", set()) +def test_make_registry_client_skips_private_package_probe(monkeypatch): + """Our client answers the probe locally without patching PlatformIO's class.""" + from platformio.account.client import AccountClient + from platformio.registry.client import RegistryClient + + pio_probe = RegistryClient.__dict__["allowed_private_packages"] + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + client = lib._make_registry_client().get_registry_client_instance() + + assert client.allowed_private_packages() is False + assert RegistryClient.__dict__["allowed_private_packages"] is pio_probe + + +def test_make_registry_client_creates_http_cache_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The HTTP cache dir exists before PlatformIO's non-exist_ok makedirs runs.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + + lib._make_registry_client() + assert (tmp_path / "http").is_dir() + # A second client (another build) must not trip over the existing dir + lib._make_registry_client() + + def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( @@ -388,6 +421,28 @@ def test_convert_libraries_redownloads_when_manifest_missing( assert top[0].data["name"] == "A" +def test_convert_libraries_manifest_optional_uses_default_layout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A backend accepting manifest-less libraries (the host build, like + PlatformIO's native platform) gets a default manifest instead of a + re-download that would repeat every build.""" + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=True + ) + emitted: list[ConvertedLibrary] = [] + backend = _backend(emit=emitted.append) + backend.manifest_optional = True + + with caplog.at_level(logging.DEBUG, logger="esphome.platformio.library"): + top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend) + + assert calls == [False] + assert top[0].data == {"name": "esphome/A"} + assert emitted == top + assert "has no manifest; using PlatformIO's default layout" in caplog.text + + def test_convert_libraries_raises_when_manifest_missing_after_retry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1232,3 +1287,50 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet( _backend(), ) assert "has no version to resolve" not in caplog.text + + +def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None: + """symlink:// is PlatformIO's other spelling for a local library folder.""" + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "benchmark"})) + url = src.as_uri().replace("file://", "symlink://", 1) + + # Both the name=URL form and an explicit repository take the scheme + for library in ( + Library(f"benchmark={url}", None, None), + Library("benchmark", None, url), + ): + top = convert_libraries([library], _backend()) + assert isinstance(top[0].source, LocalSource) + assert top[0].source_path == src + + +def test_convert_libraries_incompatible_names_the_platform_without_a_framework( + setup_core: Path, +) -> None: + """The host has no framework; the error must not read 'compatible with None'.""" + src = setup_core / "lib_dev" + src.mkdir() + (src / "library.json").write_text( + json.dumps({"name": "Only32", "platforms": "espressif32"}) + ) + backend = _backend() + backend.platform = "native" + backend.framework = None + with pytest.raises(RuntimeError, match="Only32 is not compatible with native"): + convert_libraries([Library("Only32", None, src.as_uri())], backend) + + +@pytest.mark.parametrize( + ("url", "message"), + [ + ("symlink://host/lib", "Unsupported host in symlink:// library URL"), + ("symlink:lib_dev", "symlink:// library URL .* must be an absolute"), + ], +) +def test_convert_libraries_symlink_url_errors_name_the_scheme( + setup_core: Path, url: str, message: str +) -> None: + with pytest.raises(RuntimeError, match=message): + convert_libraries([Library("benchmark", None, url)], _backend()) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py new file mode 100644 index 00000000000..af9ec4a8377 --- /dev/null +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -0,0 +1,554 @@ +"""Tests for esphome/platformio/pch.py.script against a fake SCons env.""" + +from __future__ import annotations + +from collections.abc import Callable +import os +from pathlib import Path +import stat +import subprocess +import sys +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import pch +from esphome.platformio import toolchain + +pytestmark = pytest.mark.skipif( + os.name == "nt", reason="the fake compiler is a POSIX shell script" +) + +_SCRIPT = Path(toolchain.__file__).parent / "pch.py.script" + + +class _FakePlatform: + name = "fake" + packages = {"framework-x": {}, "toolchain-y": {}} + + def get_package_version(self, name: str) -> str | None: + # None for an optional package that is not installed + return None if name == "toolchain-y" else "1.2.3" + + +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for pch.py.""" + + def __init__( + self, + proj_dir: Path, + src_dir: Path, + cxx: str, + flags: list[str], + platform_cls: type[_FakePlatform] = _FakePlatform, + ): + super().__init__(ENV={}) + self._subst = { + "$PROJECT_DIR": str(proj_dir), + "$PROJECT_SRC_DIR": str(src_dir), + "$CXX": cxx, + } + self._flags = flags + self._platform_cls = platform_cls + self.prepended: list[str] = [] + + def subst(self, expr: str) -> str: # noqa: N802 + return self._subst[expr] + + def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802 + return [self._flags] + + def PioPlatform(self) -> _FakePlatform: # noqa: N802 + return self._platform_cls() + + def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803 + self.prepended = CXXFLAGS + + def Flatten(self, nodes: list) -> list: # noqa: N802 + return nodes + + def GetProjectOption(self, name: str, default: list) -> list: # noqa: N802 + return self.get(f"option:{name}", default) + + +def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: + """A compiler stand-in that records its argv and writes the -o target.""" + cxx = tmp_path / "fake-gxx" + # As GCC: the program's path when it exists next to the driver, else its + # name; the version from FAKE_GCC_VERSION + body = ( + 'case "$1" in -print-prog-name=*) n=${1#*=};' + ' p="$(dirname "$0")/../libexec/gcc/arm-none-eabi/10.3.1/$n";' + ' [ -x "$p" ] && echo "$p" || echo "$n"; exit 0;;' + ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;;' + ' --version) echo "${FAKE_GCC_BANNER:-fake-g++ (test) ${FAKE_GCC_VERSION:-10.3.1}}";' + " exit 0;; esac\n" + 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + ) + if fail: + body += "echo boom >&2\nexit 1\n" + else: + body += 'out=""; prev=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\n' + body += '[ -n "$out" ] && echo gch > "$out"\n' + cxx.write_text("#!/bin/sh\n" + body) + cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC) + return cxx + + +def _run_script( + tmp_path: Path, + flags: list[str] | None = None, + fail: bool = False, + env_vars: dict[str, str] | None = None, + name: str = "dev", + platform_cls: type[_FakePlatform] = _FakePlatform, + build_files: Callable[[tuple], list] | None = None, + cxx: Path | None = None, +) -> _FakeSConsEnv: + proj = tmp_path / name + src = proj / "src" + (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) + (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") + (src / "esphome" / "core" / "pch_prefix.h").write_text( + '#include "esphome/core/defines.h"\n' + ) + if cxx is None: + cxx = _fake_cxx(tmp_path, fail=fail) + args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) + # Distinct objects: the -include flags must land on projenv only + global_env = _FakeSConsEnv(*args) + projenv = _FakeSConsEnv(*args) + projenv.global_env = global_env + if build_files is not None: + global_env["PIOBUILDFILES"] = build_files(args) + source = _SCRIPT.read_text() + with patch.dict(os.environ, env_vars or {}, clear=True): + exec( # noqa: S102 + compile(source, "pch.py", "exec"), + { + "Import": lambda *_names: None, + "env": global_env, + "projenv": projenv, + "COMMAND_LINE_TARGETS": [], + }, + ) + return projenv + + +def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None: + scons_env = _run_script(tmp_path) + proj = tmp_path / "dev" + assert (proj / "esphome_pch_src.h").read_text() == pch.pch_header_text( + pch.PCH_DEFAULT_HEADERS + ) + assert (proj / "esphome_pch.h").read_text() == pch.PCH_GUARD_TEXT + assert (proj / "esphome_pch.h.gch").is_file() + assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 + assert scons_env.prepended == pch.pch_consumer_flags() + # The -include flags are scoped to projenv (src compiles) + assert scons_env.global_env.prepended == [] + + +class _LibreTinyPlatform(_FakePlatform): + name = "libretiny" + + +def _run_on_host( + tmp_path: Path, + host: str, + machine: str, + platform_cls: type[_FakePlatform], + cxx: Path | None = None, + gcc_version: str = "10.3.1", +) -> _FakeSConsEnv: + with ( + patch.object(sys, "platform", host), + patch("platform.machine", return_value=machine), + ): + return _run_script( + tmp_path, + platform_cls=platform_cls, + cxx=cxx, + env_vars={"FAKE_GCC_VERSION": gcc_version}, + ) + + +def _fake_toolchain(tmp_path: Path) -> tuple[Path, Path]: + """A driver in bin/ and a cc1plus that records its argv in libexec/.""" + toolchain = tmp_path / "toolchain" + (toolchain / "bin").mkdir(parents=True) + cxx = _fake_cxx(toolchain / "bin") + real = toolchain / "libexec" / "gcc" / "arm-none-eabi" / "10.3.1" / "cc1plus" + real.parent.mkdir(parents=True) + real.write_text('#!/bin/sh\nprintf \'%s\\n\' "$@" > "$0.argv"\n') + real.chmod(0o755) + return cxx, real + + +def test_pch_script_gcc10_wrapper_on_apple_silicon(tmp_path: Path) -> None: + """The LibreTiny .gch compile and the consumers get a -B directory + holding a cc1plus that starts the real one.""" + cxx, real = _fake_toolchain(tmp_path) + scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) + wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" + assert wrapper.stat().st_mode & stat.S_IXUSR + assert repr(str(real)) in wrapper.read_text(encoding="utf-8") + argv = Path(f"{cxx}.argv").read_text(encoding="utf-8").split("\n") + assert f"-B{pch.PCH_CC1_DIR}/" in argv + assert scons_env.prepended == [f"-B{pch.PCH_CC1_DIR}/", *pch.pch_consumer_flags()] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="the wrapper is macOS only") +def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus(tmp_path: Path) -> None: + cxx, real = _fake_toolchain(tmp_path) + _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) + wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" + result = subprocess.run( + [str(wrapper), "-quiet", "x.cpp"], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + assert Path(f"{real}.argv").read_text(encoding="utf-8") == "-quiet\nx.cpp\n" + + +def test_pch_script_gcc10_without_cc1plus_builds_plainly( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A driver without a cc1plus of its own gets no wrapper and no header.""" + scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform) + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert scons_env.prepended == [] + assert "compiling without it" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("host", "machine"), [("darwin", "x86_64"), ("win32", "AMD64")] +) +def test_pch_script_gcc10_skipped_elsewhere( + tmp_path: Path, host: str, machine: str, capsys: pytest.CaptureFixture[str] +) -> None: + """Where the GCC 10 .gch cannot load, the build runs without it.""" + scons_env = _run_on_host(tmp_path, host, machine, _LibreTinyPlatform) + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert scons_env.prepended == [] + assert "compiling without it" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("host", "machine", "platform_cls", "gcc_version"), + [ + ("darwin", "arm64", _FakePlatform, "10.3.1"), + ("linux", "aarch64", _LibreTinyPlatform, "10.3.1"), + # From GCC 12 the .gch loads at any address, so the rule retires itself + ("darwin", "arm64", _LibreTinyPlatform, "12.2.0"), + # Windows needs the fix for GCC bug 14940 as well + ("win32", "AMD64", _LibreTinyPlatform, "14.4.0"), + ], +) +def test_pch_script_no_wrapper_where_the_gch_loads( + tmp_path: Path, + host: str, + machine: str, + platform_cls: type[_FakePlatform], + gcc_version: str, +) -> None: + scons_env = _run_on_host( + tmp_path, host, machine, platform_cls, gcc_version=gcc_version + ) + assert not (tmp_path / "dev" / pch.PCH_CC1_DIR).exists() + assert scons_env.prepended == pch.pch_consumer_flags() + + +def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: + """The script cannot import esphome, so its copies are pinned.""" + namespace: dict[str, object] = { + "Import": lambda *_names: None, + "env": _FakeSConsEnv(tmp_path, tmp_path, "g++", []), + "projenv": None, + "COMMAND_LINE_TARGETS": ["nobuild"], + } + exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102 + assert namespace["_HEADER_NAME"] == pch.PCH_HEADER_NAME + assert namespace["_SOURCE_NAME"] == pch.PCH_SOURCE_NAME + assert namespace["_DEFAULT_HEADERS"] == pch.PCH_DEFAULT_HEADERS + assert namespace["_CONSUMER_FLAGS"] == pch.pch_consumer_flags() + assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT + assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern + assert namespace["_CC1_DIR"] == pch.PCH_CC1_DIR + assert namespace["_WINDOWS_GCC_FIXED"] == pch.PCH_WINDOWS_GCC_FIXED + assert namespace["_WINDOWS_GCC_FIXED_DEFAULT"] == pch.PCH_WINDOWS_GCC_FIXED_DEFAULT + + +@pytest.mark.parametrize( + ("version", "env_vars", "on"), + [ + ("14.2.0", {}, False), + ("14.4.0", {}, True), + ("14.2.0", {"ESPHOME_PCH_ENABLE": "1"}, True), + ], +) +def test_pch_script_asks_the_compiler_on_windows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + version: str, + env_vars: dict[str, str], + on: bool, +) -> None: + """The Python rule again, plus the normalized knob.""" + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script( + tmp_path, env_vars={"FAKE_GCC_VERSION": version, **env_vars} + ) + assert (scons_env.prepended == pch.pch_consumer_flags()) is on + assert (tmp_path / "dev" / "esphome_pch.h.gch").is_file() is on + out = capsys.readouterr().out + assert ("cannot load a precompiled header on Windows" in out) is not on + + +def test_pch_script_spares_another_compiler_the_gcc_rule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script( + tmp_path, env_vars={"FAKE_GCC_BANNER": "clang version 15.0.0"} + ) + assert scons_env.prepended == pch.pch_consumer_flags() + + +def test_pch_script_leaves_the_header_off_when_the_compiler_cannot_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script(tmp_path, cxx=tmp_path / "missing-g++") + assert scons_env.prepended == [] + assert "GCC of unknown version cannot load" in capsys.readouterr().out + + +def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None: + """The pch holds only ESPHome's own headers: a failure is a defect.""" + with pytest.raises(RuntimeError, match="could not compile") as raised: + _run_script(tmp_path, fail=True) + assert "boom" in str(raised.value) + assert not (tmp_path / "dev" / "esphome_pch.h.gch.sum").exists() + + +def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None: + """One SCons element stays one compiler argv; -include pairs are + stripped from the .gch compile.""" + spaced = tmp_path / "My Configs" + spaced.mkdir() + (tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True) + (tmp_path / "dev" / "src" / "other.h").write_text("") + flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"] + _run_script(tmp_path, flags=flags) + calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n") + gch_call = next(c for c in calls if "c++-header" in c).splitlines() + assert '-DUSB_PRODUCT="Pico 2W"' in gch_call + assert str(spaced) in gch_call + assert "-include" not in gch_call + # The stripped src-resolvable -include is folded into the prefix header + pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text() + assert pch.splitlines()[0] == '#include "other.h"' + + +def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None: + """-includefoo.h folds like the separated form, matching the native path.""" + (tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True) + (tmp_path / "dev" / "src" / "other.h").write_text("") + _run_script(tmp_path, flags=["-DX=1", "-includeother.h"]) + pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text() + assert pch.splitlines()[0] == '#include "other.h"' + + +def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None: + """Regression: identical configs in different dirs share cache keys.""" + sums = [] + for name in ("dev_a", "dev_b"): + proj = tmp_path / name + _run_script( + tmp_path, + flags=["-DX=1", "-I", str(proj / "include")], + env_vars={"CCACHE_BASEDIR": str(proj)}, + name=name, + ) + sums.append((proj / "esphome_pch.h.gch.sum").read_text()) + (tmp_path / "fake-gxx").unlink() + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + assert sums[0] == sums[1] + + +def test_pch_script_uses_the_envs_of_existing_src_objects(tmp_path: Path) -> None: + """The ESP-IDF builder creates the src objects from its own environments + before this script runs; flags on projenv would never reach them.""" + made: list[_FakeSConsEnv] = [] + + def build_files(args: tuple) -> list: + src = args[1] + made.extend(_FakeSConsEnv(*args) for _ in range(3)) + return [ + SimpleNamespace(env=made[0], sources=[src / "main.cpp"]), + SimpleNamespace(env=made[0], sources=[src / "esphome" / "a.cpp"]), + # C and framework objects keep their environments untouched + SimpleNamespace(env=made[1], sources=[src / "esphome" / "b.c"]), + SimpleNamespace(env=made[2], sources=[src.parent / "lib" / "c.cpp"]), + ] + + projenv = _run_script(tmp_path, build_files=build_files) + assert made[0].prepended == pch.pch_consumer_flags() + assert made[1].prepended == made[2].prepended == projenv.prepended == [] + + +def test_copy_pch_script(tmp_path: Path) -> None: + from esphome.core import CORE + + CORE.build_path = tmp_path + toolchain.copy_pch_script() + assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text() + + +def _run_without_projenv(tmp_path: Path, targets: list[str]) -> Path: + proj = tmp_path / "dev" + (proj / "src").mkdir(parents=True) + + def strict_import(*names: str) -> None: + if "projenv" in names: + raise RuntimeError("Import of non-existent variable 'projenv'") + + env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"]) + exec( # noqa: S102 + compile(_SCRIPT.read_text(), "pch.py", "exec"), + {"Import": strict_import, "env": env, "COMMAND_LINE_TARGETS": targets}, + ) + return proj + + +def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None: + """-t nobuild never exports projenv; the script must not abort.""" + proj = _run_without_projenv(tmp_path, ["nobuild"]) + assert not (proj / "esphome_pch.h").exists() + + +def test_pch_script_build_without_projenv_stops(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="no project environment"): + _run_without_projenv(tmp_path, []) + + +def test_pch_script_stops_when_no_source_object_matches(tmp_path: Path) -> None: + """Flags on projenv would reach no compile while the log says the + precompiled header is in use.""" + + def build_files(args: tuple) -> list: + lib = args[1].parent / "lib" / "c.cpp" + return [SimpleNamespace(env=_FakeSConsEnv(*args), sources=[lib])] + + with pytest.raises(RuntimeError, match="no C\\+\\+ source takes"): + _run_script(tmp_path, build_files=build_files) + + +def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None: + """.piolibdeps and non-header files must not enter the digest (or be + read at all); package versions already cover library identity.""" + proj = tmp_path / "dev" + libdeps = proj / ".piolibdeps" / "lib" / "src" + libdeps.mkdir(parents=True) + (libdeps / "lib.h").write_text("#define A 1\n") + override = proj / "lwip_override" + override.mkdir(parents=True) + (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") + (override / "notes.txt").write_text("v1\n") + flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)] + _run_script(tmp_path, flags=flags) + first = (proj / "esphome_pch.h.gch.sum").read_text() + (libdeps / "lib.h").write_text("#define A 2\n") + (override / "notes.txt").write_text("v2\n") + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + assert (proj / "esphome_pch.h.gch.sum").read_text() == first + + +def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None: + """Generated headers in project-local -I dirs (e.g. rp2's lwip_override) + must invalidate the checksum when they change.""" + proj = tmp_path / "dev" + override = proj / "lwip_override" + override.mkdir(parents=True) + (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") + flags = ["-DX=1", "-I", str(override)] + _run_script(tmp_path, flags=flags) + first = (proj / "esphome_pch.h.gch.sum").read_text() + (override / "lwipopts.h").write_text("#define TCP_MSS 536\n") + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + assert (proj / "esphome_pch.h.gch.sum").read_text() != first + + +def test_pch_script_folds_force_includes_found_on_the_include_path( + tmp_path: Path, +) -> None: + """A framework force-include (Arduino.h) is read before the core + headers, as it was without the precompiled header.""" + _run_script(tmp_path, flags=["-DX=1", "-include", "Arduino.h"]) + source = (tmp_path / "dev" / "esphome_pch_src.h").read_text() + assert source.splitlines() == [ + '#include "Arduino.h"', + '#include "esphome/core/pch_prefix.h"', + ] + + +def test_pch_script_skipped_for_an_absolute_force_include( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + outside = tmp_path / "outside.h" + outside.write_text("") + scons_env = _run_script(tmp_path, flags=["-DX=1", "-include", str(outside)]) + assert scons_env.prepended == [] + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert "prevents the precompiled header" in capsys.readouterr().out + + +def _sum_after(tmp_path: Path, flags: list[str]) -> str: + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + return (tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text() + + +@pytest.mark.parametrize( + "option", [["-Iinclude"], ["-isystem", "include"], ["-iquoteinclude"]] +) +def test_pch_script_hashes_relative_include_dirs( + tmp_path: Path, option: list[str] +) -> None: + """Compiles run in the project root, so -Iinclude is a project dir.""" + include = tmp_path / "dev" / "include" + include.mkdir(parents=True) + (include / "user.h").write_text("#define A 1\n") + flags = ["-DX=1", *option, "-I."] + first = _sum_after(tmp_path, flags) + (include / "user.h").write_text("#define A 2\n") + assert _sum_after(tmp_path, flags) != first + + +def test_pch_script_hashes_the_sdkconfig(tmp_path: Path) -> None: + """sdkconfig.h is build output; the configuration it comes from is not.""" + proj = tmp_path / "dev" + proj.mkdir() + (proj / "sdkconfig.dev").write_text("CONFIG_X=y\n") + first = _sum_after(tmp_path, ["-DX=1"]) + (proj / "sdkconfig.dev").write_text("CONFIG_X=n\n") + assert _sum_after(tmp_path, ["-DX=1"]) != first + + +def test_pch_script_hashes_the_library_versions(tmp_path: Path) -> None: + sums = [] + for version in ("7.4.1", "7.4.2"): + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + + with patch.object( + _FakeSConsEnv, + "GetProjectOption", + lambda self, name, default, version=version: [f"ArduinoJson@{version}"], + ): + _run_script(tmp_path) + sums.append((tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text()) + assert sums[0] != sums[1] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 379ef52ebd7..ef573767f74 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -454,23 +454,96 @@ def test_uri_fetch_job_waits_out_a_briefly_held_lock(tmp_path: Path) -> None: assert dl_path.read_bytes() == b"data" -def test_lock_deadline_leaves_download_to_the_holder(tmp_path: Path) -> None: - """A lock held past the deadline means another process is fetching the - same file; skipping cleanly beats a misleading failure warning. The - tracker is still polled so a parked worker observes cancellation.""" +@pytest.mark.parametrize("staged", [b"", b"ab"]) +def test_lock_deadline_leaves_download_to_the_holder( + tmp_path: Path, staged: bytes +) -> None: + """A lock held past the deadline is another process's download; skip + cleanly, polling the tracker with what the holder has staged so far.""" dl_path = tmp_path / "archive" + (tmp_path / "archive.prefetch.part").write_bytes(staged) ticks: list[int] = [] with ( patch("esphome.framework_helpers.download_with_resume") as mock_download, patch("filelock.FileLock.acquire", side_effect=Timeout("held")), - patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), ): pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append) mock_download.assert_not_called() - assert ticks == [0] + assert ticks == [len(staged)] assert not dl_path.exists() +@pytest.mark.parametrize( + ("job", "part_name", "chunks", "expected"), + [ + ( + lambda dl_path: pf._registry_fetch_job( + MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4 + ), + "archive.part", + [b"a", b"abc"], + [1, 3, 4], + ), + ( + lambda dl_path: pf._uri_fetch_job( + MagicMock(), "https://x/a.zip", dl_path, 4 + ), + "archive.prefetch.part", + [b"ab"], + [2, 4], + ), + ], + ids=["registry", "uri"], +) +def test_lock_wait_reports_the_holders_progress( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + held_lock, + job, + part_name: str, + chunks: list[bytes], + expected: list[int], +) -> None: + """A waiting job reports the holder's part file (the staging one for a + URL job), then the full size once the holder lands the archive.""" + dl_path = tmp_path / "archive" + ticks: list[int] = [] + acquire = held_lock( + tmp_path / part_name, chunks, lambda: dl_path.write_bytes(b"abcd") + ) + with ( + patch("esphome.framework_helpers.download_with_resume") as mock_download, + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + caplog.at_level(logging.INFO), + ): + job(dl_path)(ticks.append) + mock_download.assert_not_called() + assert ticks == expected + assert caplog.text.count("Waiting for another process downloading archive") == 1 + + +def test_uri_lock_wait_prefers_the_landed_archive(tmp_path: Path, held_lock) -> None: + """Between the holder's promotion rename and its release the staging + part is gone; the landed cache file is credited instead of 0.""" + dl_path = tmp_path / "archive" + ticks: list[int] = [] + acquire = held_lock( + tmp_path / "archive.prefetch.part", + [b"ab", lambda: dl_path.write_bytes(b"abcd")], + lambda: None, + ) + with ( + patch("esphome.framework_helpers.download_with_resume") as mock_download, + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + ): + pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append) + mock_download.assert_not_called() + assert ticks == [2, 4, 4] + + def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: """A registry job that lost the download race to another process must not stamp a nonexistent archive into pio's usage.db.""" @@ -479,7 +552,7 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: with ( patch("esphome.framework_helpers.download_with_resume") as mock_download, patch("filelock.FileLock.acquire", side_effect=Timeout("held")), - patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), ): pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)( lambda done: None @@ -1152,6 +1225,20 @@ def test_main_runs_prefetch(tmp_path: Path) -> None: mock_prefetch.assert_called_once_with(tmp_path, "testenv") +def test_main_skips_private_package_probe_before_prefetch(tmp_path: Path) -> None: + """The registry probe patch is applied before any package manager runs.""" + order: list[str] = [] + with ( + patch.object(pf, "_prefetch", side_effect=lambda *_: order.append("prefetch")), + patch( + "esphome.platformio.runner.patch_registry_private_packages", + side_effect=lambda: order.append("patch"), + ), + ): + assert pf.main([str(tmp_path), "testenv"]) == 0 + assert order == ["patch", "prefetch"] + + def test_main_bad_argv_is_a_distinct_exit( caplog: pytest.LogCaptureFixture, ) -> None: @@ -1576,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1596,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] @@ -1640,7 +1727,7 @@ def test_preinstall_uses_distinct_managers_in_parallel(tmp_path: Path) -> None: barrier.wait() seed = _WaveManager(str(tmp_path)) - with patch.object(pf, "get_usable_cpu_count", return_value=2): + with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=2): pf._preinstall( seed, [ diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 6ba8691c4ec..c30cdc7d6c8 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -2,12 +2,15 @@ from __future__ import annotations -from contextlib import contextmanager +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager import json +import logging import os from pathlib import Path from unittest.mock import MagicMock, patch +from filelock import Timeout import pytest from esphome.core import EsphomeError @@ -44,7 +47,7 @@ def test_registry_download_resolves_once_per_process() -> None: @pytest.fixture(autouse=True) -def _fresh_registry_cache(): +def _fresh_registry_cache() -> Iterator[None]: # registry_download memoizes per process; tests reuse package names registry.registry_download.cache_clear() yield @@ -111,7 +114,7 @@ def _http_response(text: str) -> MagicMock: return resp -def _registry_response(files: list[dict]): +def _registry_response(files: list[dict]) -> AbstractContextManager[MagicMock]: """Patch the consolidated HTTP path to serve a canned registry response.""" payload = {"versions": [{"name": "1.0.0", "files": files}]} return patch.object( @@ -307,7 +310,65 @@ def test_install_package_downloads_via_registry(tmp_path: Path) -> None: "pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",) ) assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz" - assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42} + assert mock_download.call_args[1] == { + "sha256": "abc123", + "size": 42, + "progress": None, + } + + +def test_install_package_downloads_pinned(tmp_path: Path) -> None: + """A resolver's download is verified like a registry one, with no lookup.""" + dest = tmp_path / "pkg" + pinned = registry.Download("http://y/pinned.tar.gz", "def456", 7) + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object(registry, "registry_download") as mock_registry, + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", + "1.0.0", + dest, + [], + tmp_path / "dl", + expect=("payload",), + resolve=lambda: pinned, + ) + mock_registry.assert_not_called() + assert mock_download.call_args[0][0] == "http://y/pinned.tar.gz" + assert mock_download.call_args[1] == { + "sha256": "def456", + "size": 7, + "progress": None, + } + + +def test_install_package_mirror_wins_over_pinned(tmp_path: Path) -> None: + """A mirror override replaces the pinned download.""" + dest = tmp_path / "pkg" + with ( + patch.object(registry, "download_from_mirrors") as mock_mirrors, + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", + "1.0.0", + dest, + ["http://mirror/{VERSION}"], + tmp_path / "dl", + expect=("payload",), + resolve=lambda: registry.Download("http://y/pinned.tar.gz", "def456", 7), + ) + mock_mirrors.assert_called_once() + mock_download.assert_not_called() def test_install_package_validates_expected_layout(tmp_path: Path) -> None: @@ -495,8 +556,10 @@ def test_registry_download_non_list_system_is_named() -> None: registry.registry_download("pkg", "1.0.0") -def _resolve_for(sizes: dict[str, int | None]): - def resolve(name: str, version: str): +def _resolve_for( + sizes: dict[str, int | None], +) -> Callable[[str, str], tuple[str, str, int | None]]: + def resolve(name: str, version: str) -> tuple[str, str, int | None]: size = sizes[name] if size == -1: raise EsphomeError("registry down") @@ -516,8 +579,8 @@ def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -534,38 +597,124 @@ def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None assert callable(call[1]["progress"]) +def test_prefetch_packages_uses_pinned_download(tmp_path: Path) -> None: + """A package with a resolver skips the registry; the rest use it.""" + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10}) + ) as mock_registry, + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", [], ("payload",)), + ("b", "2.0", tmp_path / "b", [], ("payload",)), + ], + tmp_path / "dl", + {"b": lambda: registry.Download("http://y/b.tar.gz", "def456", 20)}, + ) + mock_registry.assert_called_once_with("a", "1.0") + calls = sorted(mock_download.call_args_list, key=lambda c: c[0][0]) + assert [c[0][0] for c in calls] == ["http://x/a.tar.gz", "http://y/b.tar.gz"] + assert calls[1][1]["sha256"] == "def456" + assert calls[1][1]["size"] == 20 + + def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: """A dest whose marker appeared while the worker waited on the lock is already installed; re-downloading would orphan an archive copy.""" dest = tmp_path / "a" dest.mkdir() - from contextlib import contextmanager - - @contextmanager - def marker_appears_under_lock(path, **kwargs): + def marker_appears_under_lock(*args, **kwargs): # Simulates the concurrent build finishing while we waited (dest / ".esphome_extracted").touch() - yield with ( - patch("filelock.FileLock", side_effect=marker_appears_under_lock), + patch("filelock.FileLock.acquire", side_effect=marker_appears_under_lock), + patch("filelock.FileLock.release"), patch.object(registry, "download_with_resume") as mock_download, patch.object( registry, "registry_download", side_effect=_resolve_for({"a": 10}) ), ): - registry.prefetch_packages([("a", "1.0", dest, [])], tmp_path / "dl") + registry.prefetch_packages([("a", "1.0", dest, [], ())], tmp_path / "dl") mock_download.assert_not_called() -def test_already_installed_probe(tmp_path: Path) -> None: +def test_prefetch_packages_waits_with_the_holders_progress( + tmp_path: Path, held_lock +) -> None: + """A worker parked on another build's lock reports that build's part + file, then the full size once the marker appears.""" + dest = tmp_path / "a" + dest.mkdir() + ticks: list[int] = [] + part = tmp_path / "dl" / "a-1.0.part" + + def installed_and_pruned() -> None: + # install_package touches the marker, then unlinks the archive + (dest / ".esphome_extracted").touch() + part.unlink() + + acquire = held_lock( + part, + [lambda: None, b"abc", installed_and_pruned], + (dest / ".esphome_extracted").touch, + ) + + def fake_batch(header, jobs): + for _name, _size, fetch in jobs: + fetch(ticks.append) + return [] + + with ( + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + patch.object(registry, "run_batch_downloads", side_effect=fake_batch), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5}) + ), + ): + registry.prefetch_packages( + [("a", "1.0", dest, [], ()), ("b", "2.0", tmp_path / "b", [], ())], + tmp_path / "dl", + ) + assert ticks == [0, 3, 10, 10] + mock_download.assert_called_once() + + +def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder( + tmp_path: Path, +) -> None: + """Past the deadline the worker skips; install_package waits on the same + lock later and verifies whatever the holder produced.""" + with ( + patch("filelock.FileLock.acquire", side_effect=Timeout("held")), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), + ], + tmp_path / "dl", + ) + mock_download.assert_not_called() + + +def test_is_installed_probe(tmp_path: Path) -> None: """Both arms of the marker probe the prefetch worker keys on.""" dest = tmp_path / "pkg" dest.mkdir() - assert registry._already_installed(dest) is False + assert registry.is_installed(dest) is False (dest / ".esphome_extracted").touch() - assert registry._already_installed(dest) is True + assert registry.is_installed(dest) is True def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None: @@ -579,8 +728,8 @@ def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("a", "1.0", tmp_path / "a", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("a", "1.0", tmp_path / "a", [], ()), ], tmp_path / "dl", ) @@ -601,8 +750,8 @@ def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", marker_dest, []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", marker_dest, [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -624,9 +773,9 @@ def test_prefetch_packages_mirror_and_sizeless_stay_sequential( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]), - ("b", "2.0", tmp_path / "b", []), - ("c", "3.0", tmp_path / "c", []), + ("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"], ()), + ("b", "2.0", tmp_path / "b", [], ()), + ("c", "3.0", tmp_path / "c", [], ()), ], tmp_path / "dl", ) @@ -647,8 +796,8 @@ def test_prefetch_packages_resolve_failure_defers_to_install( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -669,8 +818,8 @@ def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], dl, ) @@ -692,8 +841,8 @@ def test_prefetch_packages_download_failure_is_debug( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -717,9 +866,225 @@ def test_prefetch_packages_unexpected_failure_warns( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) assert "TypeError" in caplog.text + + +def _spec( + name: str, + version: str, + dest: Path, + mirrors: list[str] | None = None, + expect: tuple[str, ...] = ("payload",), +) -> registry.PackageSpec: + return registry.PackageSpec(name, version, dest, mirrors or [], expect) + + +def test_install_packages_extracts_verified_archives_in_parallel( + tmp_path: Path, +) -> None: + """Two prefetched archives install concurrently under one shared bar.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x" * 10) + (dl / "b-2.0").write_bytes(b"y" * 20) + with patch.object(registry, "install_package") as mock_install: + registry.install_packages( + [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl + ) + assert mock_install.call_count == 2 + calls = sorted(mock_install.call_args_list, key=lambda c: c[0][0]) + for c, (name, version) in zip(calls, [("a", "1.0"), ("b", "2.0")], strict=True): + assert c[0][:3] == (name, version, tmp_path / name) + assert c[1]["expect"] == ("payload",) + assert callable(c[1]["extract_progress"]) + # Driving the tracker exercises the fraction-to-bytes scaling + c[1]["extract_progress"](0.5) + c[1]["extract_progress"](1.0) + + +def test_install_packages_single_archive_stays_sequential(tmp_path: Path) -> None: + """One verified archive has nothing to parallelize; original order kept.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x") + specs = [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")] + with patch.object(registry, "install_package") as mock_install: + registry.install_packages(specs, dl) + assert [c[0][0] for c in mock_install.call_args_list] == ["a", "b"] + for c in mock_install.call_args_list: + assert "extract_progress" not in c[1] + + +def test_batched_download_progress_announces_a_real_download_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """A batched archive that fails verification streams again behind a bar + that cannot move, so it says so once; a verified archive credits itself + in one tick and stays quiet.""" + ticks: list[float] = [] + with caplog.at_level(logging.INFO): + progress = registry._batched_download_progress("pkg", "1.0.0", ticks.append) + progress(0) + progress(4096) + assert caplog.text.count("Re-downloading pkg 1.0.0") == 1 + # The shared bar never moves for a download; it tracks extraction + assert ticks == [0.0, 0.0] + + # A resumed .part starts mid-file, so the first tick is not zero + caplog.clear() + ticks.clear() + with caplog.at_level(logging.INFO): + resumed = registry._batched_download_progress("pkg", "1.0.0", ticks.append) + resumed(8192) + resumed(16384) + assert caplog.text.count("Re-downloading pkg 1.0.0") == 1 + + caplog.clear() + ticks.clear() + with caplog.at_level(logging.INFO): + verified = registry._batched_download_progress("pkg", "1.0.0", ticks.append) + verified(42) + assert "Re-downloading" not in caplog.text + assert ticks == [0.0] + + +def test_install_packages_no_batch_logs_no_header( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The batch header must not describe a batch that never ran.""" + dl = tmp_path / "dl" + dl.mkdir() + specs = [_spec("a", "1.0", tmp_path / "a")] + with ( + caplog.at_level(logging.INFO), + patch.object(registry, "install_package"), + ): + registry.install_packages(specs, dl) + assert "Extracting 0" not in caplog.text + assert "package archive(s) with" not in caplog.text + + +def test_install_packages_mirror_and_marker_stay_sequential(tmp_path: Path) -> None: + """Mirror overrides and marker hits never enter the parallel batch.""" + dl = tmp_path / "dl" + dl.mkdir() + for name, ver in (("a", "1.0"), ("b", "2.0"), ("c", "3.0"), ("d", "4.0")): + (dl / f"{name}-{ver}").write_bytes(b"x") + marked = tmp_path / "c" + marked.mkdir() + (marked / ".esphome_extracted").touch() + specs = [ + _spec("a", "1.0", tmp_path / "a"), + _spec("b", "2.0", tmp_path / "b", mirrors=["http://m"]), + _spec("c", "3.0", marked), + _spec("d", "4.0", tmp_path / "d"), + ] + with patch.object(registry, "install_package") as mock_install: + registry.install_packages(specs, dl) + sequential = [ + c for c in mock_install.call_args_list if "extract_progress" not in c[1] + ] + batched = [c for c in mock_install.call_args_list if "extract_progress" in c[1]] + assert sorted(c[0][0] for c in sequential) == ["b", "c"] + assert sorted(c[0][0] for c in batched) == ["a", "d"] + + +def test_install_packages_first_failure_reraised( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Installs are mandatory: the first failure propagates, extras are logged.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x") + (dl / "b-2.0").write_bytes(b"y") + boom = EsphomeError("bad layout") + + def _fail(name: str, *_a, **_kw) -> None: + raise boom if name == "a" else EsphomeError("also bad") + + with ( + patch.object(registry, "install_package", side_effect=_fail), + pytest.raises(EsphomeError), + ): + registry.install_packages( + [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl + ) + # Every failure is named, including the re-raised one: its exception + # message may not identify the package + assert "Could not install a" in caplog.text + assert "Could not install b" in caplog.text + + +@contextmanager +def _batched_install( + tmp_path: Path, + extract_progress: Callable[[float], None] | None, + prefill_archive: bool = True, +) -> Iterator[tuple[MagicMock, MagicMock]]: + """Run a batched install_package of pkg@1.0.0; yields the download and + extract mocks.""" + dest = tmp_path / "pkg" + if prefill_archive: + (tmp_path / "dl").mkdir() + (tmp_path / "dl" / "pkg-1.0.0").write_bytes(b"x") + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object( + registry, + "registry_download", + return_value=("http://x/pkg.tar.gz", "abc123", 42), + ), + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", + "1.0.0", + dest, + [], + tmp_path / "dl", + expect=("payload",), + extract_progress=extract_progress, + ) + yield mock_download, mock_extract + + +def test_install_package_extract_progress_suppresses_bars( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A batched install routes extraction fractions to the caller and keeps + both private bars and per-package INFO lines off the shared bar.""" + fractions: list[float] = [] + with ( + caplog.at_level(logging.INFO), + _batched_install(tmp_path, fractions.append) as (mock_download, mock_extract), + ): + pass + assert mock_extract.call_args[1]["progress"] == fractions.append + # The download tracker reports zero bytes, keeping the shared bar honest + download_progress = mock_download.call_args[1]["progress"] + assert callable(download_progress) + download_progress(42) + assert fractions == [0.0] + assert "Downloading pkg" not in caplog.text + assert "Extracting pkg" not in caplog.text + + +def test_install_package_batched_missing_archive_keeps_info_log( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A batched archive that unexpectedly needs a real download keeps the + INFO line; the shared bar shows no progress for it.""" + with ( + caplog.at_level(logging.INFO), + _batched_install(tmp_path, lambda _frac: None, prefill_archive=False), + ): + pass + assert "Downloading pkg 1.0.0" in caplog.text diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index f375aa457ac..007455f45a3 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -6,7 +6,9 @@ from collections.abc import Callable import io import sys from types import ModuleType +from unittest.mock import Mock +from platformio.registry.client import RegistryClient import pytest from esphome.platformio import runner @@ -30,6 +32,7 @@ def _prepare_main( monkeypatch.setattr(sys, "stderr", stream) monkeypatch.setattr(runner, "patch_structhash", lambda: None) monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + monkeypatch.setattr(runner, "patch_registry_private_packages", lambda: None) platformio = ModuleType("platformio") platformio_main = ModuleType("platformio.__main__") @@ -91,3 +94,40 @@ def test_main_still_filters_a_drained_partial_line( assert runner.main() == 0 assert buf.getvalue() == b"" + + +def test_main_applies_registry_private_packages_patch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The probe is patched before PlatformIO runs.""" + order: list[str] = [] + _prepare_main(monkeypatch, lambda: order.append("pio") or 0) + monkeypatch.setattr( + runner, "patch_registry_private_packages", lambda: order.append("patch") + ) + + assert runner.main() == 0 + assert order == ["patch", "pio"] + + +# Snapshot PlatformIO's own probe at import, before any test can patch it +_PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] + + +def test_patch_registry_private_packages_skips_account_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Answers False without touching the account client.""" + from platformio.account.client import AccountClient + + monkeypatch.setattr(RegistryClient, "allowed_private_packages", _PIO_PROBE) + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + runner.patch_registry_private_packages() + + assert RegistryClient.allowed_private_packages() is False + assert RegistryClient().allowed_private_packages() is False diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index fb99ea92084..60a5d1d4235 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -330,6 +330,55 @@ def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: _ = toolchain.IDEData({"extra": None}).extra_flash_images +@pytest.mark.parametrize( + ("platform", "expected"), [("esp8266", "true"), ("nrf52", None)] +) +def test_run_platformio_cli_exports_the_pch_ccache_settings( + setup_core: Path, + mock_run_external_process: Mock, + platform: str, + expected: str | None, +) -> None: + """Only for a platform that takes the pch script.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: "arduino", + } + + with patch.dict(os.environ, {}, clear=True): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get("CCACHE_PCH_EXTSUM") == expected + + +@pytest.mark.parametrize( + ("value", "expected"), [("yes", "1"), ("0", "0"), (None, None)] +) +def test_run_platformio_cli_normalizes_a_forced_pch_for_the_script( + setup_core: Path, + mock_run_external_process: Mock, + value: str | None, + expected: str | None, +) -> None: + """The script cannot import the knob parser, so it only reads a ``1``.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + env_vars = {} if value is None else {"ESPHOME_PCH_ENABLE": value} + with patch.dict(os.environ, env_vars, clear=True): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get("ESPHOME_PCH_ENABLE") == expected + + @pytest.mark.parametrize( ("platform", "framework", "expected"), [ @@ -447,6 +496,47 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ +def test_ccache_env_uses_cache_dir_override(setup_core: Path, tmp_path: Path) -> None: + """The containers point ccache at their writable cache mount.""" + CORE.build_path = setup_core / "build" / "test" + ccache_dir = tmp_path / "cache" / "platformio-ccache" + + with ( + patch.dict( + os.environ, + {"HOME": "/", "ESPHOME_PLATFORMIO_CCACHE_DIR": str(ccache_dir)}, + clear=True, + ), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), + ): + env = toolchain._ccache_env() + + assert env["CCACHE_DIR"] == str(ccache_dir.resolve()) + + +def test_ccache_env_ignores_platformio_cache_dir( + setup_core: Path, tmp_path: Path +) -> None: + """PLATFORMIO_CACHE_DIR does not move ccache; only the override does.""" + CORE.build_path = setup_core / "build" / "test" + cache_root = tmp_path / "user-cache" + + with ( + patch.dict( + os.environ, + {"PLATFORMIO_CACHE_DIR": str(tmp_path / "platformio" / "cache")}, + clear=True, + ), + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), + ): + env = toolchain._ccache_env() + + assert env["CCACHE_DIR"] == str((cache_root / "platformio-ccache").resolve()) + + @pytest.mark.parametrize( ("env_vars", "expect_warning"), [ @@ -582,6 +672,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( user_env = { "CCACHE_DIR": "/custom/cache", "CCACHE_BASEDIR": "/stale/other-device", + "ESPHOME_PLATFORMIO_CCACHE_DIR": "/mounted/platformio-ccache", } CORE.build_path = setup_core / "build" / "test" diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 0c0852a191e..245184f2d03 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -4,6 +4,8 @@ from __future__ import annotations import json from pathlib import Path +import struct +from unittest.mock import patch import pytest @@ -17,64 +19,106 @@ def _write_size_json(tmp_path: Path, data: dict) -> Path: return out +def _write_partitions(tmp_path: Path) -> Path: + """Drop a partitions.csv with a 0x1C0000 (1835008 byte) app slot.""" + out = tmp_path / "partitions.csv" + out.write_text( + "# name, type, subtype, offset, size, flags\n" + "app0, app, ota_0, 0x10000, 0x1C0000,\n" + ) + return out + + +def _elf_bytes(sections: list[tuple[int, int, int]], shentsize: int = 40) -> bytes: + """Build a minimal ELF32 LE whose section headers carry the given + (sh_type, sh_flags, sh_size) triples.""" + out = bytearray(52) + out[0:4] = b"\x7fELF" + out[4] = out[5] = 1 # 32-bit, little-endian + struct.pack_into(" dict: - """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + """Synthetic json2 for the original ESP32 (split IRAM/DRAM), in the + esp-idf-size >= 2.1 shape that carries ``total_size``.""" return { - "image_size": 827455, - "memory_types": { - "DRAM": { - "size": 180736, + "version": "1.1", + "total_size": 827455, + "layout": [ + { + "name": "DRAM", + "total": 180736, "used": 47332, - "sections": { - ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, - ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + "free": 133404, + "parts": { + ".bss": {"size": 30616}, + ".data": {"size": 16716}, }, }, - "IRAM": { - "size": 131072, + { + "name": "IRAM", + "total": 131072, "used": 80351, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 79323}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 50721, + "parts": { + ".text": {"size": 79323}, + ".vectors": {"size": 1028}, }, }, - }, + ], } def _s3_size_data() -> dict: - """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + """Synthetic json2 for ESP32-S3 (unified DIRAM), in the esp-idf-size 1.x + shape without ``total_size``.""" return { - "image_size": 724215, - "memory_types": { - "DIRAM": { - "size": 341760, + "version": "1.1", + "layout": [ + { + "name": "DIRAM", + "total": 341760, "used": 104999, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 58051}, - ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, - ".dram0.data": {"abbrev_name": ".data", "size": 19708}, - ".noinit": {"abbrev_name": ".noinit", "size": 152}, + "free": 236761, + "parts": { + ".text": {"size": 58051}, + ".bss": {"size": 27088}, + ".data": {"size": 19708}, + ".noinit": {"size": 152}, }, }, - "IRAM": { - "size": 16384, + { + "name": "IRAM", + "total": 16384, "used": 16384, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 15356}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 0, + "parts": { + ".text": {"size": 15356}, + ".vectors": {"size": 1028}, }, }, - }, + ], } +def _print_summary_ram_only(tmp_path: Path, size_json: Path) -> None: + """Call print_summary with no partitions.csv or ELF on disk.""" + print_summary(size_json, tmp_path / "partitions.csv", tmp_path / "firmware.elf") + + def test_print_summary_esp32_uses_dram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + """Original ESP32: RAM = DRAM.used / DRAM.total.""" size_json = _write_size_json(tmp_path, _esp32_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" in out assert "used 47332 bytes from 180736 bytes" in out @@ -83,63 +127,193 @@ def test_print_summary_esp32_uses_dram( def test_print_summary_s3_falls_back_to_diram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + """ESP32-S3 with no DRAM entry falls back to DIRAM and reports raw region usage.""" size_json = _write_size_json(tmp_path, _s3_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "used 104999 bytes from 341760 bytes" in out def test_print_summary_skips_when_diram_total_collapses( - tmp_path: Path, capsys: pytest.CaptureFixture[str] + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A zero-size region drops the RAM line rather than divide by zero.""" size_json = _write_size_json( tmp_path, { - "memory_types": { - "DIRAM": { - "size": 0, - "used": 0, - "sections": {}, - }, - }, + "version": "1.1", + "layout": [{"name": "DIRAM", "total": 0, "used": 0}], }, ) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" not in out + assert "unusable region" in caplog.text def test_print_summary_handles_missing_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """Missing size json is non-fatal and prints nothing.""" - print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + _print_summary_ram_only(tmp_path, tmp_path / "does_not_exist.json") assert capsys.readouterr().out == "" -def test_print_summary_handles_no_memory_types( - tmp_path: Path, capsys: pytest.CaptureFixture[str] +def test_print_summary_handles_no_layout( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: - """A size json without ``memory_types`` still doesn't crash.""" - size_json = _write_size_json(tmp_path, {"image_size": 0}) - print_summary(size_json, partitions_csv=None) + """A size json without ``layout`` warns so schema drift is visible.""" + size_json = _write_size_json(tmp_path, {"version": "1.1"}) + _print_summary_ram_only(tmp_path, size_json) assert capsys.readouterr().out == "" - - -def test_print_summary_flash_line( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - """A partition table with an app row yields the Flash line in the exact - padded shape script/ci_memory_impact_extract.py greps.""" - size_json = _write_size_json(tmp_path, _esp32_size_data()) - partitions = tmp_path / "partitions.csv" - partitions.write_text( - "# name, type, subtype, offset, size, flags\n" - "app0, app, ota_0, 0x10000, 0x1C0000,\n" + assert any( + r.levelname == "WARNING" and "no DRAM/DIRAM region" in r.message + for r in caplog.records ) - print_summary(size_json, partitions) + + +def test_print_summary_flash_line_prefers_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """With ``total_size`` in the json, that figure wins without reading the + ELF, in the exact shape script/ci_memory_impact_extract.py greps.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + print_summary(size_json, partitions, tmp_path / "firmware.elf") out = capsys.readouterr().out assert "Flash: " in out assert "(used 827455 bytes from 1835008 bytes)" in out + + +def test_print_summary_flash_line_derives_from_elf( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A 1.x json without ``total_size`` sums the ELF's loadable PROGBITS + sections; NOBITS and non-alloc sections are excluded.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes( + _elf_bytes( + [ + (1, 0x6, 700000), # PROGBITS, alloc+exec: counted + (1, 0x2, 24215), # PROGBITS, alloc: counted + (8, 0x2, 50000), # NOBITS (.bss): excluded + (1, 0x0, 12345), # PROGBITS, no alloc (.debug_*): excluded + ] + ) + ) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 724215 bytes from 1835008 bytes)" in out + + +@pytest.mark.parametrize( + "data", + [ + pytest.param([1, 2], id="top_level_list"), + pytest.param({"version": "1.1", "layout": None}, id="layout_null"), + pytest.param({"version": "1.1", "layout": 7}, id="layout_scalar"), + ], +) +def test_print_summary_handles_unexpected_shapes( + data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A foreign-schema size json degrades to a warning, never a traceback.""" + size_json = _write_size_json(tmp_path, data) + _print_summary_ram_only(tmp_path, size_json) + assert capsys.readouterr().out == "" + + +def test_print_summary_skips_flash_on_zero_app_partition( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size app partition skips the Flash line rather than printing + a from-0-bytes figure CI would record.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = tmp_path / "partitions.csv" + partitions.write_text( + "# name, type, subtype, offset, size, flags\napp0, app, ota_0, 0x10000, 0x0,\n" + ) + print_summary(size_json, partitions, tmp_path / "firmware.elf") + out = capsys.readouterr().out + assert "Flash:" not in out + + +def test_print_summary_skips_flash_on_unreadable_partitions( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An unreadable partitions.csv is non-fatal (chmod tricks don't work + for root in CI containers, so simulate the OSError instead).""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + with patch( + "esphome.espidf.size_summary._find_app_partition_size", + side_effect=PermissionError("denied"), + ): + print_summary(size_json, partitions, tmp_path / "firmware.elf") + assert "Flash:" not in capsys.readouterr().out + + +def test_print_summary_flash_falls_back_on_bad_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero or non-int total_size falls back to the ELF instead of + printing a used-0-bytes line CI would read as a real measurement.""" + data = _s3_size_data() + data["total_size"] = 0 + size_json = _write_size_json(tmp_path, data) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes(_elf_bytes([(1, 0x2, 4096)])) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 4096 bytes from 1835008 bytes)" in out + + +_GOOD_ELF = _elf_bytes([(1, 0x2, 1024)]) + + +@pytest.mark.parametrize( + ("elf_bytes", "with_partitions"), + [ + pytest.param(None, True, id="missing_elf"), + pytest.param(b"junk", True, id="not_an_elf"), + pytest.param( + _elf_bytes([(1, 0x2, 1024)], shentsize=0), True, id="bad_shentsize" + ), + pytest.param(_GOOD_ELF[:60], True, id="truncated_table"), + pytest.param(_elf_bytes([]), True, id="no_sections"), + pytest.param(_elf_bytes([(8, 0x2, 50000)]), True, id="no_progbits"), + pytest.param(_GOOD_ELF, False, id="missing_partitions"), + ], +) +def test_print_summary_skips_flash_on_bad_input( + elf_bytes: bytes | None, + with_partitions: bool, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """An unusable ELF or missing partitions.csv skips the Flash line, not the RAM line.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + firmware_elf = tmp_path / "firmware.elf" + if elf_bytes is not None: + firmware_elf.write_bytes(elf_bytes) + if with_partitions: + _write_partitions(tmp_path) + print_summary(size_json, tmp_path / "partitions.csv", firmware_elf) + out = capsys.readouterr().out + assert "RAM:" in out + assert "Flash:" not in out + # ELF problems warn (anomaly after a successful build); a missing + # partitions.csv stays at debug + warned = any( + r.levelname == "WARNING" and "Skipping Flash summary" in r.message + for r in caplog.records + ) + assert warned == with_partitions diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 73c6e496a9f..b94a70443ce 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -730,6 +730,34 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: processor.resolve_package(package_config, substitutions.ContextVars(), []) +def test_include_non_existent_file(tmp_path: Path) -> None: + """!include with a file that cannot be opened raises cv.Invalid.""" + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include non_existent_include_file.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises( + cv.Invalid, match=r"Error including file 'non_existent_include_file.yaml'" + ) as exc_info: + substitutions.do_substitution_pass(config) + assert "main.yaml" in str(exc_info.value) + + +def test_include_broken_file(tmp_path: Path) -> None: + """!include with a file that cannot be parsed raises cv.Invalid.""" + broken_file = tmp_path / "broken_file.yaml" + broken_file.write_text("{garbage\n") + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include broken_file.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises( + cv.Invalid, match=r"Error including file 'broken_file.yaml'" + ) as exc_info: + substitutions.do_substitution_pass(config) + assert "main.yaml" in str(exc_info.value) + + def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: """!include with an undefined substitution variable raises cv.Invalid. diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 9b7d1e95040..7d401eaa653 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -58,7 +58,7 @@ esp8266: board: !secret my_secret_board """), # read_file secrets.yaml - _file_response("""my_secret_board: esp1f"""), + _file_response("""my_secret_board: d1_mini"""), ] ) @@ -83,7 +83,7 @@ esp8266: broad: !secret my_secret_board # typo here """), # read_file secrets.yaml - _file_response("""my_secret_board: esp1f"""), + _file_response("""my_secret_board: d1_mini"""), ] ) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 47feae3e3c0..a1ae7f9ec77 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -516,10 +516,13 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") - # idedata cache lives under the data dir, not the build path. + # idedata caches live under the data dir, not the build path; the + # .arduino.json variant is the native esp8266 toolchain's. idedata_cache = tmp_path / "idedata" / "test.json" idedata_cache.parent.mkdir() idedata_cache.write_text("{}") + arduino_idedata_cache = tmp_path / "idedata" / "test.arduino.json" + arduino_idedata_cache.write_text("{}") # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" @@ -580,6 +583,7 @@ def test_clean_build( assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() assert not idedata_cache.exists() + assert not arduino_idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not pio_components_dir.exists() @@ -673,6 +677,35 @@ def test_clean_build_partial_exists( assert "dependencies.lock" not in caplog.text +@patch("esphome.writer.CORE") +def test_clean_build_partial_removes_pch_artifacts( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """The PlatformIO pch sidecars live at the project root and must go in + a partial clean, like the native backend's under .pioenvs.""" + from esphome.build_helpers.pch import ( + PCH_ARTIFACT_DIRS as dirs, + PCH_ARTIFACT_NAMES as names, + ) + + assert "esphome_pch.h.gch" in names + for name in names: + (tmp_path / name).write_text("x") + for name in dirs: + (tmp_path / name).mkdir() + (tmp_path / name / "cc1plus").write_text("x") + mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs" + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + + clean_build() + + for name in (*names, *dirs): + assert not (tmp_path / name).exists() + + @patch("esphome.writer.CORE") def test_clean_build_nothing_exists( mock_core: MagicMock, @@ -1081,6 +1114,28 @@ def test_clean_all_removes_global_arduino8266_install( assert str(arduino8266_install.resolve()) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_platformio_ccache( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the PlatformIO ccache dir the containers relocate.""" + ccache_dir = tmp_path / "platformio-ccache" + (ccache_dir / "0").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_PLATFORMIO_CCACHE_DIR", str(ccache_dir)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not ccache_dir.exists() + assert str(ccache_dir.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_removes_default_cache_root( mock_core: MagicMock, diff --git a/tests/unit_tests/test_yaml_edit.py b/tests/unit_tests/test_yaml_edit.py new file mode 100644 index 00000000000..31df23dc46c --- /dev/null +++ b/tests/unit_tests/test_yaml_edit.py @@ -0,0 +1,182 @@ +"""Tests for rewriting single lines of a yaml file.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +from esphome import yaml_util +from esphome.config import do_substitution_pass +from esphome.const import CONF_ESPHOME, CONF_NAME +from esphome.core import CORE, EsphomeError +from esphome.yaml_edit import ( + LineEdit, + field_line_re, + line_at, + read_text, + rewrite, + rewritten_text, + source_of, + write_keeping_mode, +) + +YAML = """esphome: + name: kitchen # the device + +wifi: + ssid: kitchen +""" + + +def _setup(tmp_path: Path, yaml_text: str) -> Path: + """Write the yaml, point CORE at it and load it the way read_config does, + so every node carries its source range.""" + CORE.reset() + CORE.config_path = tmp_path / "test.yaml" + # Bytes, so Windows does not turn the newlines into CRLF on the way in + CORE.config_path.write_bytes(yaml_text.encode()) + CORE.raw_config = do_substitution_pass(yaml_util.load_yaml(CORE.config_path), None) + return CORE.config_path + + +def _name_edit(new_name: str) -> LineEdit: + doc, line_no = source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) + text = line_at(doc, line_no) + match = field_line_re(CONF_NAME, "kitchen").match(text) + return LineEdit(doc, line_no, text, rewrite(match, new_name)) + + +def test_rewrite_keeps_the_rest_of_the_line(tmp_path: Path) -> None: + """The value changes; indentation, quotes and the comment stay.""" + path = _setup(tmp_path, YAML.replace("name: kitchen", "name: 'kitchen'")) + edit = _name_edit("garage") + assert (edit.line, edit.new_line) == (1, " name: 'garage' # the device") + assert rewritten_text(read_text(path), [edit]) == YAML.replace( + "name: kitchen", "name: 'garage'" + ) + + +def test_rewrite_can_force_quotes() -> None: + match = field_line_re(CONF_NAME, "kitchen").match(" name: kitchen # x") + assert rewrite(match, "garage", quote='"') == ' name: "garage" # x' + + +def test_only_the_located_line_changes(tmp_path: Path) -> None: + """A lookalike `name:` under another block has its own range.""" + yaml_text = YAML + "sensor:\n - platform: template\n name: kitchen\n" + path = _setup(tmp_path, yaml_text) + text = rewritten_text(read_text(path), [_name_edit("garage")]) + assert text.endswith(" name: kitchen\n") + assert " name: garage # the device" in text + + +def test_line_endings_are_kept(tmp_path: Path) -> None: + path = _setup(tmp_path, YAML.replace("\n", "\r\n")) + assert rewritten_text(read_text(path), [_name_edit("garage")]) == YAML.replace( + "\n", "\r\n" + ).replace("name: kitchen", "name: garage") + + +def test_stale_line_is_refused(tmp_path: Path) -> None: + path = _setup(tmp_path, YAML) + edit = _name_edit("garage") + with pytest.raises(EsphomeError, match="changed since it was read"): + rewritten_text(YAML.replace("kitchen #", "pantry #"), [edit]) + with pytest.raises(EsphomeError, match="changed since it was read"): + rewritten_text("esphome:\n", [edit]) + with pytest.raises(EsphomeError, match="changed since it was read"): + line_at(path, 5) + + +def test_a_comment_needs_whitespace_and_a_scalar_is_not_empty() -> None: + """`abc#def` is one value to the loader, and a bare `key:` heads a block.""" + assert field_line_re("key", "abc").match("key: abc#def") is None + assert field_line_re("key").match("key:") is None + assert field_line_re("key").match("key: abc # c")["trail"] == " # c" + assert field_line_re("key", "abc#def").match("key: abc#def") is not None + + +def test_source_of_is_none_for_a_value_validation_added(tmp_path: Path) -> None: + """Only a key read from a file carries a range, and only a mapping read + from a file can place its keys; a mapping built in code cannot.""" + assert source_of({"name": "kitchen"}, "name") is None + _setup(tmp_path, YAML) + loaded_key = next(iter(CORE.raw_config[CONF_ESPHOME])) + assert source_of({loaded_key: "kitchen"}, CONF_NAME) is None + + +def test_source_of_is_none_for_a_merged_key(tmp_path: Path) -> None: + """A key a merge brought in points at the anchor, which other mappings + may merge as well; it is not this mapping's own line.""" + _setup( + tmp_path, + "named: &named\n name: kitchen\n\nesphome:\n <<: *named\n friendly_name: x\n", + ) + assert source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) is None + assert source_of(CORE.raw_config[CONF_ESPHOME], "friendly_name") == ( + tmp_path / "test.yaml", + 5, + ) + + +def test_source_of_names_the_file_the_loader_read(tmp_path: Path) -> None: + """An include has its own document; a symlink is reported as given.""" + (tmp_path / "base.yaml").write_bytes(b"name: kitchen\n") + _setup(tmp_path, "esphome: !include base.yaml\n") + assert source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) == ( + tmp_path / "base.yaml", + 0, + ) + target = tmp_path / "shared" / "test.yaml" + target.parent.mkdir() + target.write_bytes(YAML.encode()) + CORE.config_path.unlink() + CORE.config_path.symlink_to(target) + CORE.raw_config = yaml_util.load_yaml(CORE.config_path) + assert _name_edit("garage").path == CORE.config_path + + +@pytest.mark.skipif(sys.platform == "win32", reason="posix file modes") +def test_write_keeps_the_mode_of_the_file_or_another(tmp_path: Path) -> None: + path = _setup(tmp_path, YAML) + path.chmod(0o600) + write_keeping_mode(path, YAML) + assert path.stat().st_mode & 0o777 == 0o600 + other = tmp_path / "other.yaml" + write_keeping_mode(other, YAML, like=path) + assert other.stat().st_mode & 0o777 == 0o600 + + +def test_write_failures_say_which_step_and_why(tmp_path: Path) -> None: + """A missing mode source, a write that fails, and a mode that cannot be + put back after the write are three different situations.""" + from unittest.mock import patch + + path = _setup(tmp_path, YAML) + with pytest.raises(EsphomeError, match="Could not read the mode of .*gone.yaml"): + write_keeping_mode(path, YAML, like=tmp_path / "gone.yaml") + with ( + patch("pathlib.Path.chmod", side_effect=OSError("denied")), + pytest.raises( + EsphomeError, match="was written but could not get its mode back: denied" + ), + ): + write_keeping_mode(path, YAML) + + def refuse(*_args: object, **_kwargs: object) -> None: + raise EsphomeError(f"Could not write file at {path}") from OSError("disk full") + + with ( + patch("esphome.yaml_edit.write_file", side_effect=refuse), + pytest.raises(EsphomeError, match="Could not write file at .*: disk full"), + ): + write_keeping_mode(path, YAML) + + +def test_read_text_reports_a_file_it_cannot_decode(tmp_path: Path) -> None: + path = tmp_path / "latin1.yaml" + path.write_bytes(b"caf\xe9: 1\n") + with pytest.raises(EsphomeError, match="Error reading file"): + read_text(path) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 8e1f9c25c08..f3f73ea4dcd 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -54,34 +54,12 @@ def test_include_with_vars(fixture_path: Path) -> None: assert actual["wifi"]["ssid"] == "my_custom_ssid" -def test_loading_a_broken_yaml_file(fixture_path): - """Ensure we fallback to pure python to give good errors.""" - yaml_file = fixture_path / "yaml_util" / "broken_includetest.yaml" - - try: - yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "broken_included.yaml" in str(err) - - -def test_loading_a_yaml_file_with_a_missing_component(fixture_path): - """Ensure we show the filename for a yaml file with a missing component.""" - yaml_file = fixture_path / "yaml_util" / "missing_comp.yaml" - - try: - yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "missing_comp.yaml" in str(err) - - def test_loading_a_missing_file(fixture_path): """We throw EsphomeError when loading a missing file.""" yaml_file = fixture_path / "yaml_util" / "missing.yaml" - try: + with pytest.raises(EsphomeError, match=r"missing.yaml"): yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "missing.yaml" in str(err) def test_parsing_with_custom_loader(fixture_path): diff --git a/tests/unit_tests/test_zeroconf.py b/tests/unit_tests/test_zeroconf.py index e325eb1e267..ae801868acf 100644 --- a/tests/unit_tests/test_zeroconf.py +++ b/tests/unit_tests/test_zeroconf.py @@ -28,6 +28,7 @@ def _make_service_info( network: str | None = "wifi", friendly_name: str | None = "Living Room", version: str | None = "2025.1.0", + ota_signed: str | None = None, ) -> MagicMock: """Build a fake ``AsyncServiceInfo`` with the TXT records we care about. @@ -48,6 +49,8 @@ def _make_service_info( properties[b"friendly_name"] = friendly_name.encode() if version is not None: properties[b"version"] = version.encode() + if ota_signed is not None: + properties[b"ota_signed"] = ota_signed.encode() info.properties = properties info.load_from_cache.return_value = True return info @@ -77,6 +80,7 @@ def test_added_service_populates_import_state_and_fires_callback() -> None: assert entry.project_version == "1.0.0" assert entry.network == "wifi" assert entry.friendly_name == "Living Room" + assert entry.ota_signed is False on_update.assert_called_once_with(name, entry) @@ -206,6 +210,20 @@ def test_network_defaults_to_wifi_when_txt_absent() -> None: assert discovery.import_state[name].network == "wifi" +def test_ota_signed_txt_is_parsed() -> None: + """``ota_signed=1`` marks a device that only accepts signed OTA images. + + Lets a consumer check whether it holds a key the device trusts before + an update is attempted, instead of failing at install time. + """ + discovery = DashboardImportDiscovery() + info = _make_service_info(ota_signed="1") + name = f"signed.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + assert discovery.import_state[name].ota_signed is True + + def test_friendly_name_optional() -> None: """``friendly_name`` may be ``None`` if the device doesn't broadcast it.